diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 814b5d0..9118ebc 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -34,6 +34,8 @@ jobs: - name: Check formatting working-directory: apps/audio_modem run: dart format --output=none --set-exit-if-changed lib test + - name: Check Android source contracts + run: node tools/device-acceptance/test-android-source-contracts.mjs - name: Analyze working-directory: apps/audio_modem run: flutter analyze @@ -46,3 +48,43 @@ jobs: - name: Build Linux debug working-directory: apps/audio_modem run: flutter build linux --debug + + android-build: + name: Build Android debug APK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + - name: Install Android API 36, build tools and Flutter-compatible NDK + run: | + sdkmanager --install \ + "platforms;android-36" \ + "build-tools;36.0.0" \ + "ndk;28.2.13676358" + - name: Fetch Flutter stable + run: | + git clone --depth 1 --branch stable https://github.com/flutter/flutter.git "$RUNNER_TEMP/flutter" + echo "$RUNNER_TEMP/flutter/bin" >> "$GITHUB_PATH" + - name: Install stable Rust and Android targets for Cargokit + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustup target add \ + armv7-linux-androideabi \ + aarch64-linux-android \ + i686-linux-android \ + x86_64-linux-android + - name: Disable Flutter telemetry + run: flutter --disable-analytics + - name: Install dependencies + working-directory: apps/audio_modem + run: flutter pub get + - name: Build Android debug APK + working-directory: apps/audio_modem + run: flutter build apk --debug --no-pub diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 3a36a5c..cf8745d 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -50,6 +50,7 @@ jobs: "docs/research/live-audio-adapter-sources.md:docs/research/live-audio-adapter-sources_RU.md" "docs/research/android-live-audio-adapter-sources.md:docs/research/android-live-audio-adapter-sources_RU.md" "docs/operations/device-acceptance.md:docs/operations/device-acceptance_RU.md" + "docs/operations/android-device-acceptance-runbook.md:docs/operations/android-device-acceptance-runbook_RU.md" "spec/protocol-v1.md:spec/protocol-v1_RU.md" "spec/acoustic-1.md:spec/acoustic-1_RU.md" "spec/acoustic-2.md:spec/acoustic-2_RU.md" diff --git a/apps/audio_modem/android/app/build.gradle.kts b/apps/audio_modem/android/app/build.gradle.kts index 9ad6328..bca362d 100644 --- a/apps/audio_modem/android/app/build.gradle.kts +++ b/apps/audio_modem/android/app/build.gradle.kts @@ -17,9 +17,9 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "org.audiomodem.audio_modem" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + // Live-audio v1 uses AudioFocusRequest, introduced in Android API 26. + // Keep this explicit so the install floor matches MainActivity.availability(). + minSdk = 26 targetSdk = flutter.targetSdkVersion // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) diff --git a/apps/audio_modem/rust_builder/android/build.gradle b/apps/audio_modem/rust_builder/android/build.gradle index bfd9446..fc264c2 100644 --- a/apps/audio_modem/rust_builder/android/build.gradle +++ b/apps/audio_modem/rust_builder/android/build.gradle @@ -29,9 +29,10 @@ android { namespace 'com.flutter_rust_bridge.audio_modem_bridge' } - // Bumping the plugin compileSdkVersion requires all clients of this plugin - // to bump the version in their app. - compileSdkVersion 33 + // Keep the bridge library aligned with the host app's Flutter API 36. + // AAR metadata checks require every Android library to compile against the + // API level required by its resolved AndroidX dependencies. + compileSdkVersion 36 // Use the NDK version // declared in /android/app/build.gradle file of the Flutter project. diff --git a/apps/audio_modem/rust_builder/cargokit/gradle/plugin.gradle b/apps/audio_modem/rust_builder/cargokit/gradle/plugin.gradle index 68ff649..8c40086 100644 --- a/apps/audio_modem/rust_builder/cargokit/gradle/plugin.gradle +++ b/apps/audio_modem/rust_builder/cargokit/gradle/plugin.gradle @@ -2,7 +2,9 @@ /// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin import java.nio.file.Paths +import javax.inject.Inject import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.process.ExecOperations CargoKitPlugin.file = buildscript.sourceFile @@ -15,6 +17,9 @@ class CargoKitExtension { abstract class CargoKitBuildTask extends DefaultTask { + @Inject + abstract ExecOperations getExecOperations() + @Input String buildMode @@ -60,12 +65,12 @@ abstract class CargoKitBuildTask extends DefaultTask { def rootProjectDir = project.rootProject.projectDir if (!Os.isFamily(Os.FAMILY_WINDOWS)) { - project.exec { + execOperations.exec { commandLine 'chmod', '+x', path } } - project.exec { + execOperations.exec { executable path args "build-gradle" environment "CARGOKIT_ROOT_PROJECT_DIR", rootProjectDir diff --git a/docs/operations/android-device-acceptance-runbook.md b/docs/operations/android-device-acceptance-runbook.md new file mode 100644 index 0000000..e0f0ced --- /dev/null +++ b/docs/operations/android-device-acceptance-runbook.md @@ -0,0 +1,74 @@ +# Android device-acceptance runbook + +**Status:** Operational procedure for unexecuted measurements · **Last reviewed:** 2026-08-21 · **English (canonical)** · [Русский](android-device-acceptance-runbook_RU.md) + +This runbook describes how a hardware tester may collect a reviewable **Android speaker-to-microphone** measurement for the experimental `android-live-audio-adapter/v1`. It is not a test result, device-support list, interoperability claim, or authorization to change public compatibility wording. A source-contract check and a CI APK build only establish their respective source and artifact boundaries; this procedure is required to gather a separate physical-route observation. + +## Scope and evidence boundary + +The tested route is deliberately narrow: a bounded `acoustic1` text payload is encoded to 48 kHz mono signed PCM16 little-endian, played by a foreground Android source endpoint, acoustically received by a foreground Android sink endpoint, and decoded after the tester stops capture. It does not test duplex operation, file live transfer, Bluetooth, cable, radio, background operation, device selection, automatic recovery, encryption, or general Android support. + +| Item | Required condition | What it can establish | What it cannot establish | +| --- | --- | --- | --- | +| Build input | Use a committed revision whose Android APK CI job passed. Record that revision exactly. | A reproducible debug APK was built for that revision. | Permission, playback, capture, route, decode, or compatibility. | +| Devices | Use Android API 26+ source and sink endpoints. Record only `device_class` and, if voluntarily shareable, `public_model`. | The observed endpoint pair and API floor. | Coverage of other models or OS builds. | +| Route | `speaker_microphone`; use a quiet, controlled setup and record a precise physical arrangement in notes. | The stated physical route observation. | Bluetooth, cable, radio, noise resilience, or unrestricted acoustic operation. | +| Payload | Use the reviewed bounded text fixture and record its path and SHA-256. | Reproduction of that fixture/profile/revision combination. | Delivery of arbitrary text, images, or files. | + +## Preparation + +Prepare the APK from a successful Android CI run and identify its source commit. Do not test a locally modified checkout. Charge both endpoints, disable unrelated audio output, begin with a quiet room, and choose a short non-sensitive text fixture. Do not use conversations, personally identifying material, secrets, or customer data as a test payload. + +The adapter supports only one operation at a time. Keep **playback and capture on separate Android endpoints** for this route. Ensure that both devices show an API level of at least 26. Before the test, keep the canonical template unchanged: `tools/device-acceptance/fixtures/android-speaker-microphone-template-v1.json` is intentionally a `report_type: "template"` and must contain no observations. + +> Android guidance is to request a dangerous permission in the context of the action that needs it, then degrade gracefully if it is denied. The capture check below therefore begins only when the tester selects capture; do not try to grant microphone permission in advance merely to make a result look successful. [1] + +## Execution procedure + +Use the following sequence and write down only what was actually observed. A test that is stopped, denied, rejected, or inconclusive is valuable evidence and must not be rewritten as a success. + +| Step | Tester action | Expected implementation behavior to observe | Record in report or notes | +| --- | --- | --- | --- | +| 1. Start state | Launch the APK on both endpoints. Confirm the Android live-audio controls are available rather than showing an unavailable reason. | The app reports the source adapter’s availability decision. | API levels, device classes, app revision, and any unavailable/error wording. | +| 2. Playback boundary | On the source endpoint, select the bounded text and start speaker playback. | The app asks for playback focus immediately before output. If focus is not granted, it must not proceed with playback. [2] | `focus_outcome`: `granted`, `delayed_then_granted`, or an inconclusive/rejected outcome with the displayed error. | +| 3. Permission denial control | On the sink endpoint, select capture and deny the system microphone prompt if it appears. | Capture must not start; the app must surface the denied state without retaining captured PCM. | The denial observation in narrative notes; do not create a successful measurement report from this control. | +| 4. Contextual permission | Select capture again and grant microphone permission through the system flow. | The request occurs because the user initiated capture. The app then attempts `AudioRecord` with the v1 format. [1] | `permission_state: "granted"`; any initialization error remains evidence, not a reason to omit the run. | +| 5. Physical route | Restart the bounded playback on source and start capture on sink. Keep devices stationary at the documented spacing/orientation. Stop capture explicitly after playback ends. | Capture frames are transient until the user stop; Rust performs the decode attempt after stop. | Run count, accepted/rejected count, profile, fixture hash, route description, and observed decode or error. | +| 6. Focus interruption | During a separate run, cause a real competing audio-focus event where safe (for example, start another local media app). Do not make emergency calls. | The implementation should stop the active route and not auto-resume. Audio focus can be preempted and playback should respond to loss. [2] | Whether interruption was observed, resulting state, and `interruption_policy: "stop_no_auto_resume"` only if actually confirmed. | +| 7. Route/lifecycle interruption | During separate runs, change an audio route only if the hardware safely permits it, and separately background/stop the activity. | The host is designed to stop/release the active route on routing or lifecycle changes; `AudioRouting` provides route-change notifications. [3] | `route_change_observed` as `true` or `false`, plus an exact narrative of the action and app state. | + +Do not combine simultaneous playback and capture on one device, do not infer a route from an icon, and do not repeat a failing run until it appears successful while discarding earlier observations. Record all attempts in `run_count`; the validator requires `accepted_runs + rejected_runs = run_count`. + +## Privacy and evidence handling + +The default evidence mode is **no raw recording collected**. The application’s transient capture PCM must be discarded after the decode attempt, and the Android observation must use `raw_pcm_retention: "discarded"`. Do not commit raw PCM, WAV files, screenshots containing private data, logcat dumps containing payloads, microphone recordings, device serial numbers, account names, or location information. + +If a separate recording is legitimately required for a later private review, first obtain the tester’s consent, remove unrelated speech where feasible, store it outside the repository, and publish only the availability class plus a SHA-256 digest. The validator permits `not_collected`, `private_available_on_request`, `public_redacted`, or `public_unrestricted`; use the least-disclosing truthful value. A file digest establishes file identity, not audio quality or route support. + +## Creating and validating a measurement record + +Copy—not edit—the Android template into a new, reviewed report location. Replace every placeholder through direct observation, set `report_type` to `measurement`, and include the required `adapter_observation` object only for an Android-to-Android `speaker_microphone` report. The validator requires this exact v1 format in both `requested_format` and `effective_format`: + +```json +{ + "sample_rate_hz": 48000, + "channels": 1, + "sample_format": "pcm_s16le" +} +``` + +The same observation must truthfully contain `adapter_contract: "android-live-audio-adapter/v1"`, an API level of 26 or higher, `operation: "playback_capture"`, a granted permission state, a supported focus outcome, a boolean route-change result, `interruption_policy: "stop_no_auto_resume"`, and `raw_pcm_retention: "discarded"`. Run the validator before requesting review: + +```bash +node tools/device-acceptance/validate-report.mjs path/to/real-android-report.json +``` + +Use `observed` only for the narrow, documented measurement; use `inconclusive` for incomplete/ambiguous runs; use `rejected` for a run that did not meet its observed decode criterion. A valid JSON file proves schema conformance only. Public compatibility language remains unchanged until maintainers review the complete report, reproducibility inputs, and privacy handling. + +## References + +[1] [Android Developers: Request runtime permissions](https://developer.android.com/training/permissions/requesting) + +[2] [Android Developers: Manage audio focus](https://developer.android.com/media/optimize/audio-focus) + +[3] [Android Developers: `AudioTrack.OnRoutingChangedListener` / `AudioRouting`](https://developer.android.com/reference/android/media/AudioTrack.OnRoutingChangedListener) diff --git a/docs/operations/android-device-acceptance-runbook_RU.md b/docs/operations/android-device-acceptance-runbook_RU.md new file mode 100644 index 0000000..13be4d4 --- /dev/null +++ b/docs/operations/android-device-acceptance-runbook_RU.md @@ -0,0 +1,74 @@ +# Регламент device-acceptance для Android + +**Статус:** Операционная процедура для ещё не выполненных измерений · **Последняя проверка:** 2026-08-21 · **Русский** · [English (canonical)](android-device-acceptance-runbook.md) + +Этот регламент объясняет, как hardware tester может собрать проверяемое измерение маршрута **Android «динамик → микрофон»** для экспериментального `android-live-audio-adapter/v1`. Он не является результатом тестирования, перечнем поддерживаемых устройств, заявлением об интероперабельности или разрешением менять публичные compatibility claims. Source-contract check и CI-сборка APK подтверждают только свои source/artifact boundaries; для физического маршрута требуется отдельное наблюдение по этой процедуре. + +## Граница сценария и доказательств + +Проверяется только узкий маршрут: ограниченный текстовый payload `acoustic1` кодируется в 48 кГц, mono, signed PCM16 little-endian, воспроизводится foreground Android-источником, принимается микрофоном foreground Android-приёмника и декодируется после того, как tester остановит захват. Процедура не проверяет duplex, live-передачу файлов, Bluetooth, кабель, рацию, background operation, выбор устройства, automatic recovery, шифрование или поддержку Android в целом. + +| Элемент | Обязательное условие | Что может подтвердить | Чего не подтверждает | +| --- | --- | --- | --- | +| Сборка | Используется committed revision с успешным Android APK CI job. Revision записывается точно. | Для этой revision был собран воспроизводимый debug APK. | Permission, playback, capture, маршрут, decode или compatibility. | +| Устройства | Android source и sink с API 26+. Записываются только `device_class` и, если модель можно раскрыть, `public_model`. | Наблюдаемую пару endpoint и API floor. | Другие модели и версии ОС. | +| Маршрут | `speaker_microphone`; тихая контролируемая среда, физическая расстановка точно описана в notes. | Наблюдение указанного физического маршрута. | Bluetooth, кабель, радио, шумоустойчивость или произвольную акустику. | +| Payload | Используется reviewed bounded text fixture с path и SHA-256. | Повторение комбинации fixture/profile/revision. | Доставку любого текста, изображений или файлов. | + +## Подготовка + +Возьмите APK из успешного Android CI run и определите его source commit. Не тестируйте локально изменённую рабочую копию. Зарядите оба endpoint, отключите постороннее аудио, начните в тихом помещении и выберите короткий не чувствительный text fixture. Не используйте разговоры, персональные данные, секреты или данные пользователей. + +Adapter допускает только одну операцию за раз. Для этого маршрута держите **playback и capture на разных Android endpoint**. Убедитесь, что оба устройства имеют API не ниже 26. До теста canonical template должен остаться неизменным: `tools/device-acceptance/fixtures/android-speaker-microphone-template-v1.json` намеренно имеет `report_type: "template"` и не должен содержать observations. + +> Руководство Android предписывает запрашивать dangerous permission в контексте действия, которому она нужна, и корректно деградировать при отказе. Поэтому capture check начинается только когда tester выбирает capture; не выдавайте микрофонную permission заранее лишь ради положительного результата. [1] + +## Порядок выполнения + +Следуйте последовательности и записывайте только фактически наблюдаемое. Остановленный, отклонённый, неудачный или inconclusive запуск — полезное evidence; его нельзя переписывать как успех. + +| Шаг | Действие tester | Наблюдаемое ожидаемое поведение реализации | Что фиксировать | +| --- | --- | --- | --- | +| 1. Начальное состояние | Запустите APK на обоих endpoint. Убедитесь, что Android live-audio controls доступны, а не показывают unavailable reason. | App сообщает решение availability source adapter. | API levels, device classes, app revision и любой unavailable/error text. | +| 2. Граница playback | На source выберите bounded text и начните speaker playback. | Перед output app запрашивает playback focus. Если focus не granted, playback не должен начаться. [2] | `focus_outcome`: `granted`, `delayed_then_granted` либо inconclusive/rejected outcome с отображённой ошибкой. | +| 3. Контроль отказа permission | На sink выберите capture и откажите в system microphone prompt, если он появится. | Capture не должен начаться; app должна показать denied state без retention captured PCM. | Наблюдение отказа в narrative notes; не создавайте успешный measurement report по этому контролю. | +| 4. Контекстная permission | Повторно выберите capture и выдайте microphone permission через system flow. | Запрос появляется из user-initiated capture. Затем app пытается создать `AudioRecord` в v1 format. [1] | `permission_state: "granted"`; любая initialization error остаётся evidence, а не причиной пропустить run. | +| 5. Физический маршрут | Снова начните bounded playback на source и capture на sink. Сохраняйте documented distance/orientation. Явно остановите capture после окончания playback. | Capture frames временные до user stop; после stop Rust делает decode attempt. | Run count, accepted/rejected count, profile, fixture hash, route description и observed decode/error. | +| 6. Focus interruption | В отдельном run вызовите безопасное реальное competing audio-focus событие, например запустите другое local media app. Не совершайте emergency calls. | Реализация должна остановить active route и не auto-resume. Audio focus может быть вытеснен, playback должен реагировать на loss. [2] | Было ли interruption observed, resulting state и `interruption_policy: "stop_no_auto_resume"` только при фактическом подтверждении. | +| 7. Route/lifecycle interruption | В отдельных runs измените audio route только если это безопасно для hardware, а отдельно background/stop activity. | Host спроектирован stop/release active route на routing/lifecycle changes; `AudioRouting` даёт route-change notifications. [3] | `route_change_observed` как `true` или `false` и точное narrative action/app state. | + +Не объединяйте playback и capture на одном device, не выводите маршрут лишь по icon и не повторяйте failed run до появления успеха с удалением ранних observations. В `run_count` включаются все попытки: validator требует `accepted_runs + rejected_runs = run_count`. + +## Privacy и обращение с evidence + +Default evidence mode — **raw recording не собирается**. Временный capture PCM приложения должен быть discarded после decode attempt, а Android observation должен содержать `raw_pcm_retention: "discarded"`. Не коммитьте raw PCM, WAV, screenshots с private data, logcat dumps с payload, microphone recordings, device serial numbers, account names или location data. + +Если отдельная запись действительно требуется для будущего private review, сначала получите consent tester, по возможности удалите постороннюю речь, храните файл вне репозитория и публикуйте только availability class вместе с SHA-256 digest. Validator допускает `not_collected`, `private_available_on_request`, `public_redacted` и `public_unrestricted`; выбирайте наименее раскрывающий правдивый вариант. File digest подтверждает identity файла, но не audio quality и не route support. + +## Создание и валидация measurement record + +Скопируйте, но не редактируйте Android template в новый report, доступный для review. Заполните каждое поле только direct observation, измените `report_type` на `measurement` и включайте обязательный `adapter_observation` только для Android-to-Android `speaker_microphone` report. Validator требует в `requested_format` и `effective_format` именно такой v1 format: + +```json +{ + "sample_rate_hz": 48000, + "channels": 1, + "sample_format": "pcm_s16le" +} +``` + +То же observation правдиво содержит `adapter_contract: "android-live-audio-adapter/v1"`, API level 26+, `operation: "playback_capture"`, granted permission state, supported focus outcome, boolean route-change result, `interruption_policy: "stop_no_auto_resume"` и `raw_pcm_retention: "discarded"`. До запроса review запустите validator: + +```bash +node tools/device-acceptance/validate-report.mjs path/to/real-android-report.json +``` + +`observed` используется только для узкого documented measurement; `inconclusive` — для неполных/неоднозначных runs; `rejected` — для run, который не выполнил observed decode criterion. Valid JSON доказывает только schema conformance. Public compatibility wording не меняется, пока maintainers не review complete report, reproducibility inputs и privacy handling. + +## References + +[1] [Android Developers: Request runtime permissions](https://developer.android.com/training/permissions/requesting) + +[2] [Android Developers: Manage audio focus](https://developer.android.com/media/optimize/audio-focus) + +[3] [Android Developers: `AudioTrack.OnRoutingChangedListener` / `AudioRouting`](https://developer.android.com/reference/android/media/AudioTrack.OnRoutingChangedListener) diff --git a/docs/operations/device-acceptance.md b/docs/operations/device-acceptance.md index cab15a2..2d67412 100644 --- a/docs/operations/device-acceptance.md +++ b/docs/operations/device-acceptance.md @@ -48,6 +48,8 @@ The [Android foreground live-audio adapter RFC](../../spec/android-live-audio-ad This extension is deliberately narrow. It neither permits an unexecuted template to include device/evidence fields nor changes requirements for every other route. It rejects an Android observation with a mismatched effective format or any value that would imply raw audio retention or auto-resume. +Use the [Android device-acceptance runbook](android-device-acceptance-runbook.md) for the tester procedure. It maps contextual permission, focus, interruption, lifecycle and privacy observations to this schema without treating a procedure or valid JSON as a route-support claim. + ```bash node tools/device-acceptance/validate-report.mjs \ tools/device-acceptance/fixtures/android-speaker-microphone-template-v1.json diff --git a/docs/operations/device-acceptance_RU.md b/docs/operations/device-acceptance_RU.md index dcd16b9..40be8b8 100644 --- a/docs/operations/device-acceptance_RU.md +++ b/docs/operations/device-acceptance_RU.md @@ -50,6 +50,8 @@ Committed fixture выше намеренно является **unexecuted temp Extension намеренно узкий. Он не разрешает unexecuted template включать device/evidence fields и не изменяет требования для остальных routes. Он отклоняет Android observation с mismatched effective format или любым value, предполагающим raw audio retention либо auto-resume. +Для procedure tester используйте [Android device-acceptance runbook](android-device-acceptance-runbook_RU.md). Он связывает contextual permission, focus, interruption, lifecycle и privacy observations с этой schema, не превращая procedure или valid JSON в route-support claim. + ```bash node tools/device-acceptance/validate-report.mjs \ tools/device-acceptance/fixtures/android-speaker-microphone-template-v1.json diff --git a/tools/device-acceptance/test-android-source-contracts.mjs b/tools/device-acceptance/test-android-source-contracts.mjs new file mode 100644 index 0000000..ecf7afa --- /dev/null +++ b/tools/device-acceptance/test-android-source-contracts.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node + +/** + * Source-only guard for the Android live-audio v1 boundary. + * + * It intentionally validates declarations shared across Flutter, Kotlin, + * Gradle and the manifest without claiming an APK, emulator or physical-route + * result. Runtime/device acceptance belongs to separately reviewed evidence. + */ +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repository = resolve(here, '../..'); + +const paths = { + gradle: resolve(repository, 'apps/audio_modem/android/app/build.gradle.kts'), + bridgeGradle: resolve(repository, 'apps/audio_modem/rust_builder/android/build.gradle'), + manifest: resolve( + repository, + 'apps/audio_modem/android/app/src/main/AndroidManifest.xml', + ), + kotlin: resolve( + repository, + 'apps/audio_modem/android/app/src/main/kotlin/org/audiomodem/audio_modem/MainActivity.kt', + ), + dartAdapter: resolve( + repository, + 'apps/audio_modem/lib/platform/android_live_audio_adapter.dart', + ), + dartContract: resolve( + repository, + 'apps/audio_modem/lib/platform/live_audio_adapter.dart', + ), +}; + +const contents = Object.fromEntries( + await Promise.all( + Object.entries(paths).map(async ([key, path]) => [key, await readFile(path, 'utf8')]), + ), +); + +let failures = 0; + +function expect(source, pattern, description) { + if (pattern.test(source)) { + console.log(`PASS ${description}`); + return; + } + failures += 1; + console.error(`FAIL ${description}`); +} + +expect( + contents.gradle, + /minSdk\s*=\s*26\b/, + 'Gradle declares Android API 26 as the install floor', +); +expect( + contents.bridgeGradle, + /compileSdkVersion\s+36\b/, + 'generated Rust bridge library compiles against Android API 36', +); +expect( + contents.manifest, + //, + 'manifest declares RECORD_AUDIO', +); +expect( + contents.kotlin, + /const val CHANNEL\s*=\s*"org\.audiomodem\.audio_modem\/live_audio_v1"/, + 'Kotlin host declares the canonical MethodChannel name', +); +expect( + contents.kotlin, + /EventChannel\([^\n]+"\$CHANNEL\/capture_frames"\)/, + 'Kotlin host derives the canonical capture EventChannel name', +); +expect( + contents.kotlin, + /const val SAMPLE_RATE_HZ\s*=\s*48_000[\s\S]*const val CHANNELS\s*=\s*1[\s\S]*const val SAMPLE_FORMAT\s*=\s*"pcm_s16le"/, + 'Kotlin host declares 48 kHz mono signed PCM16 LE', +); +expect( + contents.kotlin, + /Build\.VERSION\.SDK_INT\s*<\s*Build\.VERSION_CODES\.O/, + 'Kotlin availability rejects API levels below 26', +); +expect( + contents.dartAdapter, + /static const _channelName\s*=\s*'org\.audiomodem\.audio_modem\/live_audio_v1'/, + 'Dart adapter declares the canonical MethodChannel name', +); +expect( + contents.dartAdapter, + /'\$_channelName\/capture_frames'/, + 'Dart adapter derives the canonical capture EventChannel name', +); +expect( + contents.dartAdapter, + /'sampleFormat':\s*'pcm_s16le'/, + 'Dart adapter serializes the signed PCM16 LE wire-format label', +); +expect( + contents.dartContract, + /static const audioModemV1\s*=\s*PcmStreamFormat\([\s\S]*sampleRateHz:\s*48000,[\s\S]*channels:\s*1,[\s\S]*sampleFormat:\s*PcmSampleFormat\.signedPcm16LittleEndian,/, + 'Dart contract declares 48 kHz mono signed PCM16 LE', +); + +if (failures > 0) { + console.error(`Android source contracts failed: ${failures}.`); + process.exitCode = 1; +} else { + console.log('Android source contracts passed. This is not target or hardware acceptance evidence.'); +}