diff --git a/.github/scripts/check-agents-md.sh b/.github/scripts/check-agents-md.sh new file mode 100755 index 000000000..9218e8ef5 --- /dev/null +++ b/.github/scripts/check-agents-md.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Guard the agent-neutral instructions layout: +# 1. AGENTS.md exists at the repo root (the single source of rules), +# 2. CLAUDE.md imports it with `@AGENTS.md`, +# 3. no other tracked text file refers to the old path CLAUDE.md. +# +# check-agents-md.sh [repo-root] # default: current directory +# check-agents-md.sh --self-test +# +# Used by .github/workflows/agents-md.yml. +set -euo pipefail + +check() { + local root="${1:-.}" + local failed=0 + if [[ ! -f "$root/AGENTS.md" ]]; then + echo "::error::AGENTS.md is missing at the repo root." + failed=1 + fi + if ! grep -qx '@AGENTS.md' "$root/CLAUDE.md" 2>/dev/null; then + echo "::error::CLAUDE.md must contain an '@AGENTS.md' import line." + failed=1 + fi + local stale + stale=$(git -C "$root" grep -I -n 'CLAUDE\.md' -- \ + ':!CLAUDE.md' ':!AGENTS.md' \ + ':!.github/scripts/check-agents-md.sh' ':!.github/workflows/agents-md.yml' || true) + if [[ -n "$stale" ]]; then + echo "::error::These files still refer to CLAUDE.md; point them at AGENTS.md:" + echo "$stale" + failed=1 + fi + return "$failed" +} + +self_test() { + local tmp + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' RETURN + git -C "$tmp" init -q + printf '# rules\n' > "$tmp/AGENTS.md" + printf '# FT8AF\n\n@AGENTS.md\n' > "$tmp/CLAUDE.md" + printf 'x = 1 # see AGENTS.md\n' > "$tmp/code.py" + git -C "$tmp" add -A + + check "$tmp" >/dev/null || { echo "self-test FAIL: valid layout rejected"; return 1; } + + printf 'x = 1 # see CLAUDE.md\n' > "$tmp/code.py" + git -C "$tmp" add -A + if check "$tmp" >/dev/null; then echo "self-test FAIL: stale reference accepted"; return 1; fi + printf 'x = 1\n' > "$tmp/code.py" + + printf '# FT8AF\n' > "$tmp/CLAUDE.md" + git -C "$tmp" add -A + if check "$tmp" >/dev/null; then echo "self-test FAIL: missing import accepted"; return 1; fi + printf '# FT8AF\n\n@AGENTS.md\n' > "$tmp/CLAUDE.md" + + git -C "$tmp" add -A + rm "$tmp/AGENTS.md" + git -C "$tmp" add -A + if check "$tmp" >/dev/null; then echo "self-test FAIL: missing AGENTS.md accepted"; return 1; fi + + echo "self-test OK" +} + +if [[ "${1:-}" == "--self-test" ]]; then + self_test +else + check "${1:-.}" +fi diff --git a/.github/workflows/agents-md.yml b/.github/workflows/agents-md.yml new file mode 100644 index 000000000..7b8f72f04 --- /dev/null +++ b/.github/workflows/agents-md.yml @@ -0,0 +1,27 @@ +name: Agent instructions check + +# AGENTS.md is the single source of agent/contributor rules; CLAUDE.md only +# imports it. Fail if that layout breaks or a file still points at CLAUDE.md. +# Cheap and always-run, so it can be made a required check. + +on: + pull_request: + push: + branches: [main, staging, dev] + +permissions: + contents: read + +jobs: + agents-md: + name: agents-md + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Self-test the check + run: bash .github/scripts/check-agents-md.sh --self-test + + - name: Check AGENTS.md layout and references + run: bash .github/scripts/check-agents-md.sh diff --git a/.gitignore b/.gitignore index 7bcf97a62..384c2776f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ ft8af/.gitignore 2 .FT8CN软件设计初衷及使用说明0.88版.pdf.icloud ft8af/app/src/main/assets/.nightUSGS4Layer.sqlite.icloud -# Per-contributor Claude Code notes (machine paths, device serials) - see CLAUDE.md -CLAUDE.local.md +# Per-contributor agent notes (machine paths, device serials) - see AGENTS.md +AGENTS.local.md +CLAUDE.local.md # Accidental scratch/build-artifact trees (see PR #524) uiaar/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..59780008b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,280 @@ +# FT8AF Agent & Contributor Instructions + +> **One source of rules.** This file is for every contributor, human or coding +> agent (Claude Code, Codex, Cursor, Copilot, Optio, …). `CLAUDE.md` only +> imports it. Put project rules here, not in a tool-specific file. + +> **Placeholders.** This file is written for any contributor's machine. +> Substitute your own values wherever you see `<…>`. The recurring ones: +> `` / `` — your local clone of this repo; +> `` — the home directory of a JDK 17 install; +> `` — your phone's serial from `adb devices`; +> `` — your Android SDK root. +> Machine-specific notes (your paths, device serials, rig quirks) belong in an +> untracked `AGENTS.local.md`, not here. (`CLAUDE.local.md` is also git-ignored +> and still works for Claude Code users, since Claude Code loads it automatically.) + +## Development environments + +Contributors work from two kinds of checkout; the commands differ, so figure +out which one you're on first (`uname` / the primary working directory) and +follow the matching path throughout: + +- **Windows** (``, possibly driven from WSL) — uses the + `cmd.exe /c "gradlew.bat …"` wrapper, which picks up the Android Studio JBR. +- **macOS** (``) — builds and runs unit tests natively + (`./gradlew …`, needs JDK 17). + +Either kind of machine can build, test, install, and drive a real device when +an Android phone is attached over USB debugging (an emulator is often attached +alongside it). The gradle invocation is the main difference (`gradlew.bat` +wrapper vs. `./gradlew` with JDK 17); where a section below gives a command for +one, the other's equivalent is called out inline. + +On some devices `screencap` can come back all-black during the splash +animation or when a hardware overlay is active; wait for the app to settle and +retry, and use the accessibility tree only as a fallback (`uiautomator dump` +often can't reach idle because the UI animates continuously). + +## Workflow + +**Every work item requires a pull request — no direct commits to `dev` or +`main`.** Even a one-line docs or config change goes through a feature branch and +a PR. + +**All PRs target the `dev` branch.** After finishing the code for any work item, +open a pull request against `dev` (do the work on a feature branch, then +`gh pr create --base dev`). Don't merge straight to `main`. + +**Use a git worktree for every separate line of work.** Don't switch branches in +your primary checkout — branch-switching there collides with anything else in +flight (a running build, an `adb install`, a different task). Instead spin up an +isolated worktree per task: + +``` +git worktree add ../- -b feat/ +``` + +Notes for a fresh worktree: + +- The `ft8af/app/src/main/cpp/` native sources (`ft8_lib`, `ft8af_glue`) are + **tracked** in git, so a fresh worktree builds with no manual copying. +- Build/install from inside the worktree's `ft8af` dir: Windows uses the wrapper + (`cmd.exe /c "gradlew.bat installDebug"`); macOS uses `./gradlew` (see Build & + Deploy for the JDK 17 requirement). Both can install to an attached device. + +Remove the worktree when the branch is merged: `git worktree remove `. + +When nothing else is in flight in your checkout, a feature branch in place is +fine, but a worktree per task is still the recommended default. + +## Testing + +**Every new code path requires a new test.** Any branch, helper, or behavior +you add or change must be covered by a unit test in the same PR — this is not +optional, even for small UI helpers. + +Compose `@Composable` and `DrawScope` code can't be unit-tested directly, so +extract the decision/geometry logic into a plain top-level `internal` function +or class (e.g. `buildQsoLog`, `QsoPathProjection`) and test that. Keep the +Composable a thin wrapper that just calls the extracted logic. + +The same rule applies to the iOS and desktop apps: SwiftUI views and +`App.tsx` stay thin wrappers over plain, testable functions. + +### Android + +Tests live in `ft8af/app/src/test/` (Kotlin under `.../kotlin`, Java under +`.../java`), use JUnit4 + Truth (`assertThat`), and add +`@RunWith(RobolectricTestRunner::class)` when the code under test touches +Android/Play-Services types (e.g. anything reaching `MaidenheadGrid`, +`GeneralVariables`, `LatLng`). Pure math/logic needs no runner. + +Run from the `ft8af` dir. + +**Windows:** + +``` +cmd.exe /c "gradlew.bat testDebugUnitTest" +# or a single class: +cmd.exe /c "gradlew.bat testDebugUnitTest --tests " +``` + +**macOS** (needs JDK 17 — see Build & Deploy): + +``` +export JAVA_HOME= +./gradlew testDebugUnitTest +# or a single class: +./gradlew testDebugUnitTest --tests +``` + +### iOS (`ios/`, macOS only) + +Pure logic goes in the `FT8AFKit` Swift package (`ios/FT8AFKit/Sources/…`), +with XCTest suites under `ios/FT8AFKit/Tests/`. These run on the host Mac with +no simulator, but need full Xcode for XCTest: + +``` +cd ios/FT8AFKit && swift test +``` + +With only the Command Line Tools installed, `swift test` fails with +`unable to resolve module dependency: 'XCTest'`. `swift build` still works +there, and compiles everything except the tests. + +App-level tests (`ios/FT8AF/FT8AFTests`) need full Xcode (not just the +Command Line Tools) and XcodeGen (`brew install xcodegen`). Regenerate the +project after adding files or targets: + +``` +cd ios/FT8AF && xcodegen generate +xcodebuild test -project FT8AF.xcodeproj -scheme FT8AF \ + -destination 'platform=iOS Simulator,name=' \ + CODE_SIGNING_ALLOWED=NO +``` + +(Unverified: this `xcodebuild test` command follows CI's `xcodebuild build` +invocation but hasn't yet been run on a Mac with full Xcode.) + +CI (`.github/workflows/ios.yml`) runs `swift test` but only *builds* the app; +it does not run `FT8AFTests` yet, so run them locally. + +### Desktop (`desktop/`, Tauri) + +Frontend logic lives in plain modules like `desktop/src/rig.ts` with vitest +tests next to them (`*.test.ts`). Rust unit tests are `#[cfg(test)]` modules in +`desktop/src-tauri/src/`, and integration tests are in `desktop/src-tauri/tests/`. + +``` +cd desktop && npm ci && npm test # vitest +cd desktop && npm run build # build.rs needs the frontend dist +cd desktop/src-tauri && cargo test +``` + +On Linux, `cargo test` needs the system packages listed in +`.github/workflows/desktop.yml`. CI runs `cargo test` only in the non-gating +coverage job and doesn't run vitest yet, so run both locally. + +## Build & Deploy + +After making code changes, always build and install on the connected device +when one is attached. + +**Windows:** A WSL shell typically has no Linux JDK, so `./gradlew` fails with +`JAVA_HOME is not set`. Use the Windows wrapper instead — it picks up the Android +Studio JBR automatically: + +``` +cd ft8af && cmd.exe /c "gradlew.bat installDebug" +``` + +**macOS:** AGP 8.7.3 / Gradle 8.9 need **JDK 17**; if your system JDK is older, +builds fail without pointing `JAVA_HOME` at a 17. A user-local install (e.g. a +Temurin 17 archive unpacked under your home directory — no sudo needed, unlike +the Homebrew cask) works fine; wherever it lives is your ``. The +`installDebug` task installs to *every* attached device (including the +emulator); to target only the phone, build the APK and push it with an explicit +serial: + +``` +cd ft8af && JAVA_HOME= ./gradlew assembleDebug +adb -s install -r app/build/outputs/apk/debug/app-debug.apk +``` + +To drive the UI: `adb -s exec-out screencap -p > shot.png` and +tap with `adb -s shell input tap ` (coordinates in the +device's real pixel space — check it with `adb -s shell wm size`). + +When an emulator is attached alongside the phone, Gradle's install step prints +`TimeoutException`/`Unknown API Level` warnings for the emulator and still +installs on the phone; `Installed on 1 device.` means the phone got the APK, so +ignore the emulator noise. + +When multiple devices are attached, target the phone explicitly with `-s` and +its serial (from `adb devices`). If `adb` isn't on your PATH, it lives at +`/platform-tools/adb` (on Windows +`\platform-tools\adb.exe`; Homebrew installs on macOS put it at +`/opt/homebrew/bin/adb`). + +## Debug logs + +The app writes a structured event log to +`/sdcard/Android/data/radio.ks3ckc.ft8af/files/debug.log` via `fileLog()` in +`ComposeMainActivity.kt` — CAT serial sends/recvs, USB attach events, +autoConnect attempts, band/frequency changes, etc. This is usually the most +useful source. Pull it with: + +``` +adb -s pull /sdcard/Android/data/radio.ks3ckc.ft8af/files/debug.log /tmp/ +``` + +For runtime detail not in `debug.log` (audio recording loop, system USB events, +crashes), use `adb logcat`. Useful tags: `FT8SignalListener`, `MicRecorder`, +`UsbAudioDevice`, `CableConnector`, `CableSerialPort`, `UsbHostManager`, +`UsbAlsaManager`. The app's `applicationId` is `radio.ks3ckc.ft8af` (the same +for every contributor — it's set in `ft8af/app/build.gradle`) — pid-filter with +`adb -s logcat --pid=$(adb -s shell pidof radio.ks3ckc.ft8af)` +when you only want app-internal lines. + +## FT8 TX audio pipeline + +How a `playFT8Signal` call becomes RF, with the gotchas that produce +audible-but-undecodable signals. Both of these were diagnosed the hard +way and re-introducing either makes TX silently broken — the rig keys, +audio is audible, ALC looks right, and zero spots appear on PSKReporter. + +**1. The native library generates the entire waveform.** `GenerateFT8.generateFt8(msg, +freq, 12000)` returns a mono 12.64-second `float[]` at 12 kHz containing the full +FT8 message. The Costas sync arrays at symbols 0-6, 36-42, 72-78 are +embedded by `synth_gfsk` in the native lib — today that's the from-source +`libft8af.so` (`System.loadLibrary("ft8af")`, built from the vendored +`ft8_lib`; historically this was the retired closed prebuilt `libft8cn.so`). +The buffer is correct as generated — everything +downstream just has to *not break it*. + +**2. `lateStartSkipMs` clips leading audio, but only if we'd overrun the cycle.** +FT8 audio is 12.64 s; cycle is 15 s; slack is 2.36 s. `msLate` (in +`FT8TransmitSignal.java`) must be computed as +`max(0, time_into_cycle_ms - 2360)` — **not** `time_into_cycle_ms % +15000`. The latter treats every ms past the cycle boundary as lateness, +so a normal on-time TX firing ~500-800 ms into the cycle chops that many +ms off the **start** of the buffer — exactly where the leading Costas +array lives. Receivers see audio but can't sync. Tell from log: +`playLength < samples` when the TX started <2.4 s into the cycle. Fixed +in PR #93. + +**3. `libusb_set_iso_packet_lengths` must use the audio rate, not +`wMaxPacketSize`.** A USB Audio Class device plays back exactly the bytes +per frame the host hands it. For USB FS, that's +`(sampleRate * channels * bytesPerSample) / 1000` — e.g. 192 bytes/frame +at 48 kHz stereo 16-bit. The endpoint's `wMaxPacketSize` (~200 for +C-Media CM108-style chips) is the device's *max*, not the data rate. +Sending that much per frame makes the device clock samples ~4 % faster +than negotiated, shifting every FT8 tone up by the same ratio and +pushing the message off WSJT-X's 6.25 Hz grid. Tell from log: +`UsbAudioNative.nativeWrite` returns measurably faster than the audio +duration (12.14 s real time for 12.64 s of audio). Fixed in PR #94 in +`cpp/usb_audio_capture.cpp`. The Android-standard `AudioTrack` path is +unaffected because the kernel UAC driver does this math automatically; +the bug only bites the direct-libusb path used for car-dash kernels and +similar. + +**4. The USB-direct path must force-claim the UAC AudioControl interface, +not just the streaming ones.** Claiming only the AudioStreaming interfaces +is a silent no-op for the kernel's `snd-usb-audio` driver (it binds the card +at the AudioControl interface and treats the streaming interfaces as +owned-but-unused), so the ALSA card survives and Android keeps the rig's +sound card registered as a `usb_headset` sink+source. Every sound Android +then routes there — the app's own QSO-complete alert ding, a Bluetooth +car-kit connecting, a nav prompt — makes the kernel driver flip the playback +interface's alt-setting under our in-flight iso URBs, which the kernel +completes with `-ESHUTDOWN`. Tell from log: `libusb native write FAILED +(rc=5 TRANSFER_NO_DEVICE) after ~280ms` with **no** `usbDetach` and the RX +capture still running, typically 1.9 s after an `ALERT fire` line. The +device is still on the bus; only the endpoint was torn down. Fixed by +`UsbAudioDevice.detachKernelAudioDriver()`, which claims the AudioControl +interface (that runs the real `usb_audio_disconnect`). A genuine bus drop +looks different: `usbDetach` for the hub, serial and audio devices together +and `serial.send: port not open!` — that one is electrical (RF resetting the +hub), not software. diff --git a/CLAUDE.md b/CLAUDE.md index 8ca7a9185..4297a1b51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,223 +1,5 @@ -# FT8AF Project Instructions +# FT8AF -> **Placeholders.** This file is written for any contributor's machine. -> Substitute your own values wherever you see `<…>`. The recurring ones: -> `` / `` — your local clone of this repo; -> `` — the home directory of a JDK 17 install; -> `` — your phone's serial from `adb devices`; -> `` — your Android SDK root. -> Machine-specific notes (your paths, device serials, rig quirks) belong in an -> untracked `CLAUDE.local.md`, not here. +All project instructions live in [AGENTS.md](AGENTS.md). Read it first. -## Development environments - -Contributors work from two kinds of checkout; the commands differ, so figure -out which one you're on first (`uname` / the primary working directory) and -follow the matching path throughout: - -- **Windows** (``, possibly driven from WSL) — uses the - `cmd.exe /c "gradlew.bat …"` wrapper, which picks up the Android Studio JBR. -- **macOS** (``) — builds and runs unit tests natively - (`./gradlew …`, needs JDK 17). - -Either kind of machine can build, test, install, and drive a real device when -an Android phone is attached over USB debugging (an emulator is often attached -alongside it). The gradle invocation is the main difference (`gradlew.bat` -wrapper vs. `./gradlew` with JDK 17); where a section below gives a command for -one, the other's equivalent is called out inline. - -On some devices `screencap` can come back all-black during the splash -animation or when a hardware overlay is active; wait for the app to settle and -retry, and use the accessibility tree only as a fallback (`uiautomator dump` -often can't reach idle because the UI animates continuously). - -## Workflow - -**Every work item requires a pull request — no direct commits to `dev` or -`main`.** Even a one-line docs or config change goes through a feature branch and -a PR. - -**All PRs target the `dev` branch.** After finishing the code for any work item, -open a pull request against `dev` (do the work on a feature branch, then -`gh pr create --base dev`). Don't merge straight to `main`. - -**Use a git worktree for every separate line of work.** Don't switch branches in -your primary checkout — branch-switching there collides with anything else in -flight (a running build, an `adb install`, a different task). Instead spin up an -isolated worktree per task: - -``` -git worktree add ../- -b feat/ -``` - -Notes for a fresh worktree: - -- The `ft8af/app/src/main/cpp/` native sources (`ft8_lib`, `ft8af_glue`) are - **tracked** in git, so a fresh worktree builds with no manual copying. -- Build/install from inside the worktree's `ft8af` dir: Windows uses the wrapper - (`cmd.exe /c "gradlew.bat installDebug"`); macOS uses `./gradlew` (see Build & - Deploy for the JDK 17 requirement). Both can install to an attached device. - -Remove the worktree when the branch is merged: `git worktree remove `. - -When nothing else is in flight in your checkout, a feature branch in place is -fine, but a worktree per task is still the recommended default. - -## Testing - -**Every new code path requires a new test.** Any branch, helper, or behavior -you add or change must be covered by a unit test in the same PR — this is not -optional, even for small UI helpers. - -Compose `@Composable` and `DrawScope` code can't be unit-tested directly, so -extract the decision/geometry logic into a plain top-level `internal` function -or class (e.g. `buildQsoLog`, `QsoPathProjection`) and test that. Keep the -Composable a thin wrapper that just calls the extracted logic. - -Tests live in `ft8af/app/src/test/` (Kotlin under `.../kotlin`, Java under -`.../java`), use JUnit4 + Truth (`assertThat`), and add -`@RunWith(RobolectricTestRunner::class)` when the code under test touches -Android/Play-Services types (e.g. anything reaching `MaidenheadGrid`, -`GeneralVariables`, `LatLng`). Pure math/logic needs no runner. - -Run from the `ft8af` dir. - -**Windows:** - -``` -cmd.exe /c "gradlew.bat testDebugUnitTest" -# or a single class: -cmd.exe /c "gradlew.bat testDebugUnitTest --tests " -``` - -**macOS** (needs JDK 17 — see Build & Deploy): - -``` -export JAVA_HOME= -./gradlew testDebugUnitTest -# or a single class: -./gradlew testDebugUnitTest --tests -``` - -## Build & Deploy - -After making code changes, always build and install on the connected device -when one is attached. - -**Windows:** A WSL shell typically has no Linux JDK, so `./gradlew` fails with -`JAVA_HOME is not set`. Use the Windows wrapper instead — it picks up the Android -Studio JBR automatically: - -``` -cd ft8af && cmd.exe /c "gradlew.bat installDebug" -``` - -**macOS:** AGP 8.7.3 / Gradle 8.9 need **JDK 17**; if your system JDK is older, -builds fail without pointing `JAVA_HOME` at a 17. A user-local install (e.g. a -Temurin 17 archive unpacked under your home directory — no sudo needed, unlike -the Homebrew cask) works fine; wherever it lives is your ``. The -`installDebug` task installs to *every* attached device (including the -emulator); to target only the phone, build the APK and push it with an explicit -serial: - -``` -cd ft8af && JAVA_HOME= ./gradlew assembleDebug -adb -s install -r app/build/outputs/apk/debug/app-debug.apk -``` - -To drive the UI: `adb -s exec-out screencap -p > shot.png` and -tap with `adb -s shell input tap ` (coordinates in the -device's real pixel space — check it with `adb -s shell wm size`). - -When an emulator is attached alongside the phone, Gradle's install step prints -`TimeoutException`/`Unknown API Level` warnings for the emulator and still -installs on the phone; `Installed on 1 device.` means the phone got the APK, so -ignore the emulator noise. - -When multiple devices are attached, target the phone explicitly with `-s` and -its serial (from `adb devices`). If `adb` isn't on your PATH, it lives at -`/platform-tools/adb` (on Windows -`\platform-tools\adb.exe`; Homebrew installs on macOS put it at -`/opt/homebrew/bin/adb`). - -## Debug logs - -The app writes a structured event log to -`/sdcard/Android/data/radio.ks3ckc.ft8af/files/debug.log` via `fileLog()` in -`ComposeMainActivity.kt` — CAT serial sends/recvs, USB attach events, -autoConnect attempts, band/frequency changes, etc. This is usually the most -useful source. Pull it with: - -``` -adb -s pull /sdcard/Android/data/radio.ks3ckc.ft8af/files/debug.log /tmp/ -``` - -For runtime detail not in `debug.log` (audio recording loop, system USB events, -crashes), use `adb logcat`. Useful tags: `FT8SignalListener`, `MicRecorder`, -`UsbAudioDevice`, `CableConnector`, `CableSerialPort`, `UsbHostManager`, -`UsbAlsaManager`. The app's `applicationId` is `radio.ks3ckc.ft8af` (the same -for every contributor — it's set in `ft8af/app/build.gradle`) — pid-filter with -`adb -s logcat --pid=$(adb -s shell pidof radio.ks3ckc.ft8af)` -when you only want app-internal lines. - -## FT8 TX audio pipeline - -How a `playFT8Signal` call becomes RF, with the gotchas that produce -audible-but-undecodable signals. Both of these were diagnosed the hard -way and re-introducing either makes TX silently broken — the rig keys, -audio is audible, ALC looks right, and zero spots appear on PSKReporter. - -**1. The native library generates the entire waveform.** `GenerateFT8.generateFt8(msg, -freq, 12000)` returns a mono 12.64-second `float[]` at 12 kHz containing the full -FT8 message. The Costas sync arrays at symbols 0-6, 36-42, 72-78 are -embedded by `synth_gfsk` in the native lib — today that's the from-source -`libft8af.so` (`System.loadLibrary("ft8af")`, built from the vendored -`ft8_lib`; historically this was the retired closed prebuilt `libft8cn.so`). -The buffer is correct as generated — everything -downstream just has to *not break it*. - -**2. `lateStartSkipMs` clips leading audio, but only if we'd overrun the cycle.** -FT8 audio is 12.64 s; cycle is 15 s; slack is 2.36 s. `msLate` (in -`FT8TransmitSignal.java`) must be computed as -`max(0, time_into_cycle_ms - 2360)` — **not** `time_into_cycle_ms % -15000`. The latter treats every ms past the cycle boundary as lateness, -so a normal on-time TX firing ~500-800 ms into the cycle chops that many -ms off the **start** of the buffer — exactly where the leading Costas -array lives. Receivers see audio but can't sync. Tell from log: -`playLength < samples` when the TX started <2.4 s into the cycle. Fixed -in PR #93. - -**3. `libusb_set_iso_packet_lengths` must use the audio rate, not -`wMaxPacketSize`.** A USB Audio Class device plays back exactly the bytes -per frame the host hands it. For USB FS, that's -`(sampleRate * channels * bytesPerSample) / 1000` — e.g. 192 bytes/frame -at 48 kHz stereo 16-bit. The endpoint's `wMaxPacketSize` (~200 for -C-Media CM108-style chips) is the device's *max*, not the data rate. -Sending that much per frame makes the device clock samples ~4 % faster -than negotiated, shifting every FT8 tone up by the same ratio and -pushing the message off WSJT-X's 6.25 Hz grid. Tell from log: -`UsbAudioNative.nativeWrite` returns measurably faster than the audio -duration (12.14 s real time for 12.64 s of audio). Fixed in PR #94 in -`cpp/usb_audio_capture.cpp`. The Android-standard `AudioTrack` path is -unaffected because the kernel UAC driver does this math automatically; -the bug only bites the direct-libusb path used for car-dash kernels and -similar. - -**4. The USB-direct path must force-claim the UAC AudioControl interface, -not just the streaming ones.** Claiming only the AudioStreaming interfaces -is a silent no-op for the kernel's `snd-usb-audio` driver (it binds the card -at the AudioControl interface and treats the streaming interfaces as -owned-but-unused), so the ALSA card survives and Android keeps the rig's -sound card registered as a `usb_headset` sink+source. Every sound Android -then routes there — the app's own QSO-complete alert ding, a Bluetooth -car-kit connecting, a nav prompt — makes the kernel driver flip the playback -interface's alt-setting under our in-flight iso URBs, which the kernel -completes with `-ESHUTDOWN`. Tell from log: `libusb native write FAILED -(rc=5 TRANSFER_NO_DEVICE) after ~280ms` with **no** `usbDetach` and the RX -capture still running, typically 1.9 s after an `ALERT fire` line. The -device is still on the bus; only the endpoint was torn down. Fixed by -`UsbAudioDevice.detachKernelAudioDriver()`, which claims the AudioControl -interface (that runs the real `usb_audio_disconnect`). A genuine bus drop -looks different: `usbDetach` for the hub, serial and audio devices together -and `serial.send: port not open!` — that one is electrical (RF resetting the -hub), not software. +@AGENTS.md diff --git a/README.md b/README.md index 02d00984d..ad86c2f17 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ cd ft8af --- +## Contributing + +Workflow, testing and build rules for humans and coding agents alike are in [AGENTS.md](AGENTS.md). + +--- + ## Native code The FT8 DSP core is a vendored copy of [kgoba's ft8_lib](https://github.com/kgoba/ft8_lib), pinned to a specific upstream commit — see [`ft8af/app/src/main/cpp/ft8_lib/FT8_LIB_PIN.txt`](ft8af/app/src/main/cpp/ft8_lib/FT8_LIB_PIN.txt) for the exact hash. App-specific JNI glue lives alongside it in `ft8af/app/src/main/cpp/ft8af_glue/`. All of it is built from source by the NDK/CMake toolchain into a single `libft8af.so`, so a fresh clone or worktree builds with no manual copying. diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 0a62946d6..8a720a529 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -850,7 +850,7 @@ impl Engine { self.set_ptt(true); std::thread::sleep(Duration::from_millis(PTT_DELAY_MS)); - // Clip leading audio only if we'd overrun the cycle (CLAUDE.md gotcha: + // Clip leading audio only if we'd overrun the cycle (AGENTS.md gotcha: // ms_late = max(0, into_cycle - 2360), NOT into_cycle % 15000). Computed // here, right before audio starts, so it reflects the real lateness after // the PTT settle — `maybe_transmit`'s window check keeps this at 0 in diff --git a/desktop/src/logbook.ts b/desktop/src/logbook.ts index dc7ec88c1..9984a2fd8 100644 --- a/desktop/src/logbook.ts +++ b/desktop/src/logbook.ts @@ -1,6 +1,6 @@ // Pure, framework-free logbook helpers. The React `LogScreen` is a thin wrapper // around these so the decision/normalization logic stays unit-testable (see -// CLAUDE.md: Composable/JSX code can't be tested directly — extract the logic). +// AGENTS.md: Composable/JSX code can't be tested directly — extract the logic). import type { QsoRecord } from "./ipc"; diff --git a/ft8af/app/src/main/cpp/ft8af_glue/test_golden_encode.c b/ft8af/app/src/main/cpp/ft8af_glue/test_golden_encode.c index 49a7bc6f6..214f1a0cc 100644 --- a/ft8af/app/src/main/cpp/ft8af_glue/test_golden_encode.c +++ b/ft8af/app/src/main/cpp/ft8af_glue/test_golden_encode.c @@ -32,7 +32,7 @@ // // The Costas arrays at symbols 0-6 / 36-42 / 72-78 are also asserted directly — // those are what a receiver syncs on; corrupting them yields audible-but- -// undecodable TX (see CLAUDE.md "FT8 TX audio pipeline"). +// undecodable TX (see AGENTS.md "FT8 TX audio pipeline"). // // HOW TO RUN (host build, no device/emulator needed): // ft8af/app/src/main/cpp/ft8af_glue/run_host_tests.ps1 diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 67287260a..d0ee31b75 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -161,6 +161,9 @@ public static boolean isConfiguredUsbAudioOutput(int vid, int pid) { public static boolean showTxVolumeSlider = true;//Show inline TX volume slider on main screen public static MutableLiveData mutableShowTxVolumeSlider = new MutableLiveData<>(true); + public static boolean controlsDrawerEnabled = true;//Operate controls as a collapsing bottom-sheet drawer (design 3a/3b) + public static MutableLiveData mutableControlsDrawerEnabled = new MutableLiveData<>(true); + //Save TX output level per band (issue #355), defaults off (global level only). // volatile: written from DatabaseOpr's background config-load thread and the // Settings toggle, read from UI + MeterProtectionController threads (same diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index e15102ddd..ee3b3da94 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1149,7 +1149,7 @@ public void doOnAfterQueryCallsignLocation(CallsignInfo callsignInfo) { // Voice assistant: TTS must never leak into a transmission (it would be // mixed into the rig audio and go out over the air — see the TX audio - // hazards in CLAUDE.md). Two layers: the announcer refuses to start an + // hazards in AGENTS.md). Two layers: the announcer refuses to start an // utterance while isTransmitting(), and this observer hard-stops any // in-flight speech the instant TX begins. observeForever is safe here: // the ViewModel constructor runs on the main thread (setValue above), diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index b80fb8670..6f69c32ae 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2971,6 +2971,10 @@ protected Void doInBackground(Void... voids) { GeneralVariables.showTxVolumeSlider = !result.equals("0"); GeneralVariables.mutableShowTxVolumeSlider.postValue(GeneralVariables.showTxVolumeSlider); } + if (name.equalsIgnoreCase("controlsDrawerEnabled")) {//Collapsing bottom-sheet controls drawer, defaults on + GeneralVariables.controlsDrawerEnabled = !result.equals("0"); + GeneralVariables.mutableControlsDrawerEnabled.postValue(GeneralVariables.controlsDrawerEnabled); + } if (name.equalsIgnoreCase("perBandOutputLevel")) {//Save TX output level per band, defaults off GeneralVariables.savePerBandOutputLevel = result.equals("1"); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index 5fc18369a..e2417580c 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -1072,7 +1072,7 @@ private void playViaAudioTrack(float[] buffer) { // and, on a mono or unknown device, the only possibility — keeps the // historical MONO open: the framework duplicates it to every channel, so // that path is byte-for-byte what it always was. This matters more than - // it looks; see the FT8 TX audio pipeline notes in CLAUDE.md for how + // it looks; see the FT8 TX audio pipeline notes in AGENTS.md for how // little it takes to turn an audible transmission into an undecodable one. final TxChannelLayout layout = resolveTxLayout(preferredOutputDevice); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java index 61d4b668a..6b81d9f58 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java @@ -58,10 +58,16 @@ public void setTransmitSignal(FT8TransmitSignal signal) { /** * Whether SWR is high enough to halt TX: protection enabled, a valid reading, and over - * the threshold. Pure so the rule is unit-tested independent of the rig meter plumbing. + * the threshold — but never while a user tune carrier is up. A tune is the operator + * deliberately keying to bring SWR *down* (setting an antenna tuner, checking a match), + * so high SWR is the expected starting condition, not a fault; halting there would lock + * the operator out of the very retune they pressed the button for. The tune worker has + * its own bounded max-on timeout for PA safety. Pure so the rule is unit-tested + * independent of the rig meter plumbing. */ - public static boolean shouldHaltForSwr(int normalizedSwr, boolean enabled, int threshold) { - return normalizedSwr >= 0 && enabled && normalizedSwr > threshold; + public static boolean shouldHaltForSwr(int normalizedSwr, boolean enabled, int threshold, + boolean tuning) { + return !tuning && normalizedSwr >= 0 && enabled && normalizedSwr > threshold; } /** @@ -73,6 +79,11 @@ public void onMeterUpdate(int normalizedAlc, int normalizedSwr) { if (normalizedAlc >= 0) lastAlc.postValue(normalizedAlc); if (normalizedSwr >= 0) lastSwr.postValue(normalizedSwr); + // A user tune carrier is in progress: the meter callback is live whenever PTT is + // keyed (FT8 over OR tune), so suppress the SWR halt while tuning. See + // shouldHaltForSwr — halting here would set the lockout and block the retune. + boolean tuning = transmitSignal != null && transmitSignal.isTuning(); + // Diagnostics: when SWR protection is on, record each SWR reading we actually // receive so the debug.log shows whether meter data even reaches here during TX, the // values, and the halt decision. Without this a halt that never fires leaves no trace @@ -86,17 +97,18 @@ public void onMeterUpdate(int normalizedAlc, int normalizedSwr) { if (changed || now - lastSwrLogMs >= SWR_LOG_THROTTLE_MS) { lastSwrLogMs = now; lastLoggedSwr = normalizedSwr; + String decision = tuning ? "tune (halt suppressed)" + : (shouldHaltForSwr(normalizedSwr, GeneralVariables.swrHaltEnabled, + GeneralVariables.swrHaltThreshold, false) ? "HALT" : "ok"); GeneralVariables.fileLog(String.format( "MeterProtection: SWR reading swr=%d threshold=%d -> %s", - normalizedSwr, GeneralVariables.swrHaltThreshold, - shouldHaltForSwr(normalizedSwr, GeneralVariables.swrHaltEnabled, - GeneralVariables.swrHaltThreshold) ? "HALT" : "ok")); + normalizedSwr, GeneralVariables.swrHaltThreshold, decision)); } } - // --- SWR halt check --- + // --- SWR halt check (never during a user tune) --- if (shouldHaltForSwr(normalizedSwr, GeneralVariables.swrHaltEnabled, - GeneralVariables.swrHaltThreshold)) { + GeneralVariables.swrHaltThreshold, tuning)) { haltForSwr(normalizedSwr); return; // no point adjusting volume if we just killed TX } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/TuneToneGenerator.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/TuneToneGenerator.java index 9b59be27d..940b9067b 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/TuneToneGenerator.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/TuneToneGenerator.java @@ -14,7 +14,7 @@ * *

Pure arithmetic with injected frequency/sample-rate/amplitude — no * Android, no native code — so it is deterministic and unit-testable - * (CLAUDE.md testing rule). + * (AGENTS.md testing rule). */ public final class TuneToneGenerator { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/AudioChannelSelect.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/AudioChannelSelect.java index 6888b29bc..29a8ef559 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/AudioChannelSelect.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/AudioChannelSelect.java @@ -87,7 +87,7 @@ public static boolean needsStereoCapture(int selection) { * channels by the framework and by every UAC device, which is what * {@link #BOTH} means, so the default TX path keeps its historical mono * {@code AudioTrack} untouched. Changing that path is not free — see the FT8 - * TX audio pipeline notes in CLAUDE.md — so it only happens when the + * TX audio pipeline notes in AGENTS.md — so it only happens when the * operator actually asks for one side. */ public static boolean needsStereoPlayback(int selection) { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbTransientErrorPolicy.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbTransientErrorPolicy.java index b2d756427..e9409a7f7 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbTransientErrorPolicy.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbTransientErrorPolicy.java @@ -33,7 +33,7 @@ public enum Kind { /** * Most a single isochronous packet may be re-sent before the TX path gives - * up on it. FT8 has ~2.36 s of cycle slack (see {@code CLAUDE.md} → "FT8 + * up on it. FT8 has ~2.36 s of cycle slack (see {@code AGENTS.md} → "FT8 * TX audio pipeline"), so a handful of ~1 ms packet retries is well * within budget and never pushes audio off the WSJT-X grid. */ diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index b010ab8f8..877b736d1 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -22,6 +22,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.foundation.layout.height +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -63,6 +67,9 @@ import radio.ks3ckc.ft8af.ui.components.SlotTimerBar import radio.ks3ckc.ft8af.ui.components.TabBar import radio.ks3ckc.ft8af.ui.components.TransmitGlow import radio.ks3ckc.ft8af.ui.components.TxStrip +import radio.ks3ckc.ft8af.ui.components.OperateControlsDrawer +import radio.ks3ckc.ft8af.ui.components.OperateStatusRow +import radio.ks3ckc.ft8af.ui.components.OperateDrawerPeekHeight import radio.ks3ckc.ft8af.ui.components.VoiceCommandButton import radio.ks3ckc.ft8af.ui.components.selectBandIndex import radio.ks3ckc.ft8af.ui.decode.DecodeScreen @@ -135,6 +142,22 @@ fun FT8AFApp(mainViewModel: MainViewModel) { showVolumeSlider = showVolumeSliderLive ?: GeneralVariables.showTxVolumeSlider } + // Operate-controls drawer (design 3a/3b) — observed so the Settings toggle takes effect + // immediately. When on, the always-open strip becomes a collapsing bottom-sheet drawer; + // controlsExpanded drives the open sheet and starts collapsed each session. + val controlsDrawerLive by GeneralVariables.mutableControlsDrawerEnabled.observeAsState( + GeneralVariables.controlsDrawerEnabled, + ) + var controlsDrawerEnabled by remember { mutableStateOf(GeneralVariables.controlsDrawerEnabled) } + LaunchedEffect(controlsDrawerLive) { + controlsDrawerEnabled = controlsDrawerLive ?: GeneralVariables.controlsDrawerEnabled + } + var controlsExpanded by rememberSaveable { mutableStateOf(false) } + // Window-space Y of the reserved peek slot's bottom (== tab bar top). The drawer overlay + // anchors its bottom edge here so the collapsed sheet lines up exactly with the reserved + // inline space and grows upward from there. + var drawerAnchorBottomPx by remember { mutableFloatStateOf(0f) } + // Consume the one-shot celebration signal so LiveData doesn't replay it // on recomposition / resubscription. LaunchedEffect(qsoCompletedAt) { @@ -339,6 +362,119 @@ fun FT8AFApp(mainViewModel: MainViewModel) { } } + // ---- Operating-controls: derived labels + shared action lambdas ---- + // Hoisted to function scope so the always-open TxStrip, the collapsing drawer peek (in the + // Column), and the expanded controls sheet (a sibling overlay in the outer Box) all drive + // identical rig behavior from one set of callbacks. + val slotMillis = ModeProfile.fromId(operatingMode).slotMillis.toLong() + val bandModeLabel = "$frequencyLabel · $modeName" + val huntOptionLabel = if (huntEnabled) { + stringResource( + when (huntPriority) { + HuntPriority.LATEST -> R.string.hunt_priority_latest + HuntPriority.STRONGEST -> R.string.hunt_priority_strongest + HuntPriority.WEAKEST -> R.string.hunt_priority_weakest + HuntPriority.FARTHEST -> R.string.hunt_priority_farthest + HuntPriority.POTA_FIRST -> R.string.hunt_priority_pota + HuntPriority.NEW_DXCC_FIRST -> R.string.hunt_priority_new_dxcc + HuntPriority.NEW_GRID_FIRST -> R.string.hunt_priority_new_grid + }, + ) + } else { + stringResource(R.string.tx_hunt_off) + } + + val onToggleTuneAction: () -> Unit = { + // Toggle (WSJT-X style latching Tune): tap to key the carrier, + // tap again to stop. startTune() toasts the reason when blocked. + // Per the tune-method setting the tap may instead fire the rig's + // internal ATU (issue #425) — a one-shot command, nothing to latch. + if (isTuning) { + mainViewModel.ft8TransmitSignal.stopTune() + } else if (!mainViewModel.tryStartTuneViaAtu()) { + mainViewModel.ft8TransmitSignal.startTune() + } + } + val onVolumeChangeAction: (Int) -> Unit = { newVolume -> + txVolume = newVolume + GeneralVariables.volumePercent = newVolume / 100f + GeneralVariables.mutableVolumePercent.postValue(newVolume / 100f) + } + val onVolumeChangeFinishedAction: () -> Unit = { + mainViewModel.databaseOpr.writeConfig("volumeValue", txVolume.toString(), null) + mainViewModel.baseRig?.connector?.setRFVolume(txVolume) + saveOutputLevelForCurrentBand(mainViewModel.databaseOpr, txVolume) + } + val onCallCQAction: () -> Unit = { + if (GeneralVariables.myCallsign.isNullOrEmpty()) { + Toast.makeText(context, context.getString(R.string.app_set_callsign_first), Toast.LENGTH_SHORT).show() + } else if (isFreeTextMode && freeTextMessage.isNotBlank()) { + // Free text is a one-shot (WSJT-X Tx5 style): send it once, + // immediately, then the engine auto-stops — it is an alternative + // to a 73, not a repeating CQ. Consume the armed free text so the + // next tap calls a normal CQ instead of re-sending it. + mainViewModel.ft8TransmitSignal.sendFreeTextOnce(freeTextMessage) + isFreeTextMode = false + freeTextMessage = "" + } else { + mainViewModel.ft8TransmitSignal.setTransmitFreeText(false) + mainViewModel.ft8TransmitSignal.userResetToCQ() + mainViewModel.ft8TransmitSignal.setActivated(true) + GeneralVariables.resetLaunchSupervision() + } + } + val onStopAction: () -> Unit = { + // In Hound mode the STOP button leaves Hound entirely; + // otherwise it just deactivates the normal sequencer. + if (GeneralVariables.houndMode) { + mainViewModel.stopHoundMode() + dxEnabled = false + } else { + mainViewModel.ft8TransmitSignal.setActivated(false) + // If Hunt armed this run, STOP ends Hunt too, so the buttons can't be + // left showing Hunt "on" while the sequencer is stopped (and idle). + if (huntEnabled) { + huntEnabled = false + GeneralVariables.autoFollowCQ = false + mainViewModel.databaseOpr.writeConfig("autoFollowCQ", "0", null) + } + } + // Clear free text and Field Day mode on stop + isFreeTextMode = false + freeTextMessage = "" + mainViewModel.ft8TransmitSignal.setTransmitFreeText(false) + if (fieldDayEnabled) { + fieldDayEnabled = false + GeneralVariables.fieldDayMode = false + mainViewModel.databaseOpr.writeConfig("fieldDayMode", "0", null) + cqModifier = "" + GeneralVariables.toModifier = "" + mainViewModel.databaseOpr.writeConfig("toModifier", "", null) + } + } + val onOpenCqOptionsAction: () -> Unit = { showCqOptions = true } + val onToggleDxAction: () -> Unit = { + if (dxEnabled || GeneralVariables.houndMode) { + mainViewModel.stopHoundMode() + dxEnabled = false + } else { + showHoundSetup = true + } + } + val onSelectTxPeriodAction: (Int) -> Unit = { newSlot -> + mainViewModel.ft8TransmitSignal.sequential = newSlot + mainViewModel.ft8TransmitSignal.mutableSequential.postValue(newSlot) + // Switching slots mid-QSO abandons the current contact. + val target = mainViewModel.ft8TransmitSignal.mutableToCallsign.value + if (FT8TransmitSignal.shouldResetTargetOnSlotToggle(target?.callsign)) { + mainViewModel.ft8TransmitSignal.userResetToCQ() + } + } + val onToggleHuntAction: () -> Unit = { setHuntEnabled(!huntEnabled) } + val onOpenHuntOptionsAction: () -> Unit = { showHuntOptions = true } + val onReconnectCatAction: () -> Unit = { mainViewModel.reconnectRig() } + val onOpenBandModeAction: () -> Unit = { showBandModeSheet = true } + Box(modifier = Modifier.fillMaxSize().background(BgApp)) { // Keep the content clear of the display cutout (and the status bar, should it // ever be visible) — the Box behind still paints BgApp into the padded band so a @@ -449,135 +585,66 @@ fun FT8AFApp(mainViewModel: MainViewModel) { offsetSec = avgDtSec, ) - // TX status strip — always visible above tab bar (redesigned, option 3a). The - // slot bar + clock-sync pill remain in the SlotTimerBar above; the strip's - // status row shows the plain-language "next transmit window" countdown. - val slotMillis = ModeProfile.fromId(operatingMode).slotMillis.toLong() - val bandModeLabel = "$frequencyLabel · $modeName" - val huntOptionLabel = if (huntEnabled) { - stringResource( - when (huntPriority) { - HuntPriority.LATEST -> R.string.hunt_priority_latest - HuntPriority.STRONGEST -> R.string.hunt_priority_strongest - HuntPriority.WEAKEST -> R.string.hunt_priority_weakest - HuntPriority.FARTHEST -> R.string.hunt_priority_farthest - HuntPriority.POTA_FIRST -> R.string.hunt_priority_pota - HuntPriority.NEW_DXCC_FIRST -> R.string.hunt_priority_new_dxcc - HuntPriority.NEW_GRID_FIRST -> R.string.hunt_priority_new_grid - }, + // TX operating controls above the tab bar. When the drawer setting is on (default) + // they collapse into a bottom-sheet drawer (design 3a/3b): the peek shows status + + // the primary action + TX period, and the full control set opens in the + // OperateControlsSheet overlay below. When off, the original always-open strip shows. + // The slot bar + clock-sync pill remain in the SlotTimerBar above. + if (controlsDrawerEnabled) { + // Docked status line (design 3a), then a reserved slot the drawer sheet sits in + // when collapsed. The actual sheet is the OperateControlsDrawer overlay in the + // outer Box; it anchors its bottom to this slot and grows upward on expand. + OperateStatusRow( + isTransmitting = isTransmitting, + isTuning = isTuning, + slotMillis = slotMillis, + txSlot = txSlot, + showCatChip = showCatChip, + catState = catState, + onReconnectCat = onReconnectCatAction, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(OperateDrawerPeekHeight) + .onGloballyPositioned { coords -> + drawerAnchorBottomPx = coords.positionInWindow().y + coords.size.height + }, ) } else { - stringResource(R.string.tx_hunt_off) + TxStrip( + isTransmitting = isTransmitting, + isActivated = isActivated, + bandModeLabel = bandModeLabel, + slotMillis = slotMillis, + txSlot = txSlot, + huntEnabled = huntEnabled, + huntOptionLabel = huntOptionLabel, + dxEnabled = dxEnabled, + catState = catState, + showCatChip = showCatChip, + txVolume = txVolume, + showVolumeSlider = showVolumeSlider, + cqModifier = cqModifier, + isFreeTextMode = isFreeTextMode, + fieldDayEnabled = fieldDayEnabled, + isTuning = isTuning, + tuneRemainingSec = tuneRemainingSec, + onToggleTune = onToggleTuneAction, + onVolumeChange = onVolumeChangeAction, + onVolumeChangeFinished = onVolumeChangeFinishedAction, + onCallCQ = onCallCQAction, + onStop = onStopAction, + onOpenCqOptions = onOpenCqOptionsAction, + onToggleDx = onToggleDxAction, + onSelectTxPeriod = onSelectTxPeriodAction, + onToggleHunt = onToggleHuntAction, + onOpenHuntOptions = onOpenHuntOptionsAction, + onReconnectCat = onReconnectCatAction, + onOpenBandMode = onOpenBandModeAction, + ) } - TxStrip( - isTransmitting = isTransmitting, - isActivated = isActivated, - bandModeLabel = bandModeLabel, - slotMillis = slotMillis, - txSlot = txSlot, - huntEnabled = huntEnabled, - huntOptionLabel = huntOptionLabel, - dxEnabled = dxEnabled, - catState = catState, - showCatChip = showCatChip, - txVolume = txVolume, - showVolumeSlider = showVolumeSlider, - cqModifier = cqModifier, - isFreeTextMode = isFreeTextMode, - fieldDayEnabled = fieldDayEnabled, - isTuning = isTuning, - tuneRemainingSec = tuneRemainingSec, - onToggleTune = { - // Toggle (WSJT-X style latching Tune): tap to key the carrier, - // tap again to stop. startTune() toasts the reason when blocked. - // Per the tune-method setting the tap may instead fire the rig's - // internal ATU (issue #425) — a one-shot command, nothing to latch. - if (isTuning) { - mainViewModel.ft8TransmitSignal.stopTune() - } else if (!mainViewModel.tryStartTuneViaAtu()) { - mainViewModel.ft8TransmitSignal.startTune() - } - }, - onVolumeChange = { newVolume -> - txVolume = newVolume - GeneralVariables.volumePercent = newVolume / 100f - GeneralVariables.mutableVolumePercent.postValue(newVolume / 100f) - }, - onVolumeChangeFinished = { - mainViewModel.databaseOpr.writeConfig("volumeValue", txVolume.toString(), null) - mainViewModel.baseRig?.connector?.setRFVolume(txVolume) - saveOutputLevelForCurrentBand(mainViewModel.databaseOpr, txVolume) - }, - onCallCQ = { - if (GeneralVariables.myCallsign.isNullOrEmpty()) { - Toast.makeText(context, context.getString(R.string.app_set_callsign_first), Toast.LENGTH_SHORT).show() - } else if (isFreeTextMode && freeTextMessage.isNotBlank()) { - // Free text is a one-shot (WSJT-X Tx5 style): send it once, - // immediately, then the engine auto-stops — it is an alternative - // to a 73, not a repeating CQ. Consume the armed free text so the - // next tap calls a normal CQ instead of re-sending it. - mainViewModel.ft8TransmitSignal.sendFreeTextOnce(freeTextMessage) - isFreeTextMode = false - freeTextMessage = "" - } else { - mainViewModel.ft8TransmitSignal.setTransmitFreeText(false) - mainViewModel.ft8TransmitSignal.userResetToCQ() - mainViewModel.ft8TransmitSignal.setActivated(true) - GeneralVariables.resetLaunchSupervision() - } - }, - onStop = { - // In Hound mode the STOP button leaves Hound entirely; - // otherwise it just deactivates the normal sequencer. - if (GeneralVariables.houndMode) { - mainViewModel.stopHoundMode() - dxEnabled = false - } else { - mainViewModel.ft8TransmitSignal.setActivated(false) - // If Hunt armed this run, STOP ends Hunt too, so the buttons can't be - // left showing Hunt "on" while the sequencer is stopped (and idle). - if (huntEnabled) { - huntEnabled = false - GeneralVariables.autoFollowCQ = false - mainViewModel.databaseOpr.writeConfig("autoFollowCQ", "0", null) - } - } - // Clear free text and Field Day mode on stop - isFreeTextMode = false - freeTextMessage = "" - mainViewModel.ft8TransmitSignal.setTransmitFreeText(false) - if (fieldDayEnabled) { - fieldDayEnabled = false - GeneralVariables.fieldDayMode = false - mainViewModel.databaseOpr.writeConfig("fieldDayMode", "0", null) - cqModifier = "" - GeneralVariables.toModifier = "" - mainViewModel.databaseOpr.writeConfig("toModifier", "", null) - } - }, - onOpenCqOptions = { showCqOptions = true }, - onToggleDx = { - if (dxEnabled || GeneralVariables.houndMode) { - mainViewModel.stopHoundMode() - dxEnabled = false - } else { - showHoundSetup = true - } - }, - onSelectTxPeriod = { newSlot -> - mainViewModel.ft8TransmitSignal.sequential = newSlot - mainViewModel.ft8TransmitSignal.mutableSequential.postValue(newSlot) - // Switching slots mid-QSO abandons the current contact. - val target = mainViewModel.ft8TransmitSignal.mutableToCallsign.value - if (FT8TransmitSignal.shouldResetTargetOnSlotToggle(target?.callsign)) { - mainViewModel.ft8TransmitSignal.userResetToCQ() - } - }, - onToggleHunt = { setHuntEnabled(!huntEnabled) }, - onOpenHuntOptions = { showHuntOptions = true }, - onReconnectCat = { mainViewModel.reconnectRig() }, - onOpenBandMode = { showBandModeSheet = true }, - ) // Bottom tab bar TabBar( @@ -595,6 +662,42 @@ fun FT8AFApp(mainViewModel: MainViewModel) { // One-shot particle burst when a QSO completes. QsoCelebration(triggerAt = qsoCompletedAt) + // Operate-controls drawer (design 3a/3b) — one bottom sheet that grows in place from the + // reserved peek slot. Sibling overlay so its scrim + sheet sit above the tab bar and + // content. Rendered only when the drawer setting is on. + if (controlsDrawerEnabled) { + OperateControlsDrawer( + expanded = controlsExpanded, + onExpandedChange = { controlsExpanded = it }, + anchorBottomPx = drawerAnchorBottomPx, + isTransmitting = isTransmitting, + isActivated = isActivated, + isTuning = isTuning, + bandModeLabel = bandModeLabel, + txSlot = txSlot, + huntEnabled = huntEnabled, + huntOptionLabel = huntOptionLabel, + dxEnabled = dxEnabled, + txVolume = txVolume, + showVolumeSlider = showVolumeSlider, + cqModifier = cqModifier, + isFreeTextMode = isFreeTextMode, + fieldDayEnabled = fieldDayEnabled, + tuneRemainingSec = tuneRemainingSec, + onCallCQ = onCallCQAction, + onStop = onStopAction, + onSelectTxPeriod = onSelectTxPeriodAction, + onToggleHunt = onToggleHuntAction, + onOpenHuntOptions = onOpenHuntOptionsAction, + onOpenBandMode = onOpenBandModeAction, + onToggleTune = onToggleTuneAction, + onToggleDx = onToggleDxAction, + onOpenCqOptions = onOpenCqOptionsAction, + onVolumeChange = onVolumeChangeAction, + onVolumeChangeFinished = onVolumeChangeFinishedAction, + ) + } + // Frequency picker — sibling overlay so the scrim and sheet sit above the // tab bar and TxStrip. FrequencyPickerSheet( diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt new file mode 100644 index 000000000..3117e27fd --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt @@ -0,0 +1,329 @@ +package radio.ks3ckc.ft8af.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.k1af.ft8af.R +import kotlin.math.roundToInt +import kotlinx.coroutines.launch +import radio.ks3ckc.ft8af.theme.* + +/** + * Given the drawer's current state and the net vertical drag applied to the handle (negative = + * upward), decide the state to settle into when the drag ends. An upward drag past the threshold + * from the collapsed peek expands it; a downward drag past the threshold from expanded collapses + * it; anything short of the threshold leaves the state unchanged. Extracted so the gesture rule + * can be unit-tested without Compose. + */ +internal fun drawerTargetExpanded( + currentlyExpanded: Boolean, + dragDy: Float, + thresholdPx: Float, +): Boolean = when { + !currentlyExpanded && dragDy <= -thresholdPx -> true + currentlyExpanded && dragDy >= thresholdPx -> false + else -> currentlyExpanded +} + +/** The vertical drag distance on the handle that commits to toggling the drawer. */ +internal val DrawerDragThreshold = 48.dp + +/** + * The collapsed height of the drawer — the handle (with its "More controls" hint) plus the primary + * Call CQ + Hunt row, stopping before the next row so nothing peeks through when collapsed. + */ +internal val OperateDrawerPeekHeight = 120.dp + +/** + * The operate-controls drawer (design 3a/3b) as a *single* bottom sheet that grows in place. + * + * The sheet is anchored so its bottom sits on [anchorBottomPx] (the window-space Y that the + * reserved peek slot in the main column occupies, i.e. just above the tab bar). Its content is a + * single top-aligned column — a grab handle, the primary Call CQ / Stop + Hunt row, then the + * secondary controls (Band & Mode, TX period / Tune / DX, power). Collapsed, the sheet is + * [OperateDrawerPeekHeight] tall and only the handle + primary row show (3a); dragging the handle + * up or tapping it grows the *same* surface upward to reveal the rest over a scrim (3b). Nothing + * slides over the primary controls — it is one sheet expanding, not a second sheet on top. + */ +@Composable +fun OperateControlsDrawer( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + anchorBottomPx: Float, + isTransmitting: Boolean, + isActivated: Boolean, + isTuning: Boolean, + bandModeLabel: String, + txSlot: Int, + huntEnabled: Boolean, + huntOptionLabel: String, + dxEnabled: Boolean, + txVolume: Int, + showVolumeSlider: Boolean, + cqModifier: String, + isFreeTextMode: Boolean, + fieldDayEnabled: Boolean, + tuneRemainingSec: Int, + onCallCQ: () -> Unit, + onStop: () -> Unit, + onSelectTxPeriod: (Int) -> Unit, + onToggleHunt: () -> Unit, + onOpenHuntOptions: () -> Unit, + onOpenBandMode: () -> Unit, + onToggleTune: () -> Unit, + onToggleDx: () -> Unit, + onOpenCqOptions: () -> Unit, + onVolumeChange: (Int) -> Unit, + onVolumeChangeFinished: () -> Unit, +) { + // Not laid out yet — the reserved peek slot hasn't reported its position. Skip a frame. + if (anchorBottomPx <= 0f) return + + val density = LocalDensity.current + val peekPx = with(density) { OperateDrawerPeekHeight.toPx() } + val thresholdPx = with(density) { DrawerDragThreshold.toPx() } + val scope = rememberCoroutineScope() + + // Full natural height of the content, measured independently of the (clipped) sheet height. + var fullContentPx by remember { mutableFloatStateOf(peekPx) } + val rangePx = (fullContentPx - peekPx).coerceAtLeast(1f) + + // fraction 0 = collapsed peek, 1 = fully expanded. Animated on tap; follows the finger on drag. + val fraction = remember { Animatable(if (expanded) 1f else 0f) } + LaunchedEffect(expanded) { fraction.animateTo(if (expanded) 1f else 0f, tween(220)) } + + val sheetHeightPx = peekPx + rangePx * fraction.value + val scrimAlpha = 0.55f * fraction.value + + val actions = txStripActionState(isActivated, huntEnabled) + + Box(modifier = Modifier.fillMaxSize()) { + // Scrim over the content above the sheet only (stops at the tab bar so the tabs stay + // usable). Tappable to collapse once the sheet is mostly open. + if (scrimAlpha > 0.01f) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(density) { anchorBottomPx.toDp() }) + .background(Color.Black.copy(alpha = scrimAlpha)) + .then( + if (fraction.value > 0.5f) { + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onExpandedChange(false) } + } else { + Modifier + } + ) + ) + } + + // The sheet — positioned so its bottom edge lands on anchorBottomPx and it grows upward. + Box( + modifier = Modifier + .fillMaxWidth() + .offset { IntOffset(0, (anchorBottomPx - sheetHeightPx).roundToInt()) } + .height(with(density) { sheetHeightPx.toDp() }) + .clip(RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp)) + .background(BgSurface3) + .clipToBounds(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + // Measure the natural (unbounded) content height so the expanded target is + // exact; the parent Box clips it to the animated height. + .wrapContentHeight(align = Alignment.Top, unbounded = true) + .onSizeChanged { fullContentPx = it.height.toFloat() }, + ) { + DrawerHandle( + expanded = expanded, + onToggle = { onExpandedChange(!expanded) }, + onDrag = { dy -> + val next = (fraction.value - dy / rangePx).coerceIn(0f, 1f) + scope.launch { fraction.snapTo(next) } + }, + onDragEnd = { totalDy -> + val target = drawerTargetExpanded(expanded, totalDy, thresholdPx) + scope.launch { fraction.animateTo(if (target) 1f else 0f, tween(220)) } + onExpandedChange(target) + }, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // ---- Primary row (visible in the peek): Call CQ / Stop + Hunt ---- + CqHuntRow( + actions = actions, + huntOptionLabel = huntOptionLabel, + cqModifier = cqModifier, + isFreeTextMode = isFreeTextMode, + fieldDayEnabled = fieldDayEnabled, + onCallCQ = onCallCQ, + onStop = onStop, + onOpenCqOptions = onOpenCqOptions, + onToggleHunt = onToggleHunt, + onOpenHuntOptions = onOpenHuntOptions, + ) + + // ---- Secondary controls, revealed as the sheet grows (design 3b) ---- + BandModeRow(bandModeLabel = bandModeLabel, onOpenBandMode = onOpenBandMode) + + PeriodTuneDxRow( + txSlot = txSlot, + isActivated = isActivated, + isTransmitting = isTransmitting, + isTuning = isTuning, + tuneRemainingSec = tuneRemainingSec, + dxEnabled = dxEnabled, + onSelectTxPeriod = onSelectTxPeriod, + onToggleTune = onToggleTune, + onToggleDx = onToggleDx, + ) + + if (showVolumeSlider) { + TxVolumeRow( + txVolume = txVolume, + onVolumeChange = onVolumeChange, + onVolumeChangeFinished = onVolumeChangeFinished, + ) + } + } + } + } + } +} + +/** + * The drawer's grab handle: a centered grabber bar plus — while collapsed — an up-chevron and + * "More controls" caption that gently bob to advertise that the sheet pulls up. A tap toggles the + * drawer; a vertical drag is fed live to [onDrag] (dy, negative = up) and settled in [onDragEnd] + * with the total drag distance. + */ +@Composable +private fun DrawerHandle( + expanded: Boolean, + onToggle: () -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + var totalDy by remember { mutableFloatStateOf(0f) } + val expandLabel = stringResource(R.string.controls_drawer_expand) + val collapseLabel = stringResource(R.string.controls_drawer_collapse) + val label = if (expanded) collapseLabel else expandLabel + + // Gentle up-and-down bob on the hint, only while collapsed, to draw the eye to the affordance. + val bobTransition = rememberInfiniteTransition(label = "drawer-hint-bob") + val bob by bobTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "drawer-hint-bob-offset", + ) + + Column( + modifier = modifier + .fillMaxWidth() + .height(42.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onToggle() } + .semantics { role = Role.Button; contentDescription = label } + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragStart = { totalDy = 0f }, + onDragEnd = { onDragEnd(totalDy); totalDy = 0f }, + onDragCancel = { totalDy = 0f }, + onVerticalDrag = { _, dy -> + totalDy += dy + onDrag(dy) + }, + ) + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp, Alignment.CenterVertically), + ) { + Box( + modifier = Modifier + .width(36.dp) + .height(4.dp) + .clip(RoundedCornerShape(99.dp)) + .background(Color(0x6694A3B8)), // rgba(148,163,184,0.40) + ) + if (expanded) { + // Down-chevron when open — a plain "pull down to collapse" hint. + FT8AFIcons.ChevronDown(size = 14.dp, color = TextFaint, strokeWidth = 2.2f) + } else { + Row( + modifier = Modifier.offset(y = (bob * -3f).dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + // Up-chevron (the down-chevron glyph rotated) to say "pulls up". + Box(modifier = Modifier.rotate(180f)) { + FT8AFIcons.ChevronDown(size = 13.dp, color = Accent, strokeWidth = 2.4f) + } + Text( + text = stringResource(R.string.controls_drawer_more), + color = Accent, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + letterSpacing = 0.2.sp, + ) + } + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt index 24a238582..5d67e1b1a 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt @@ -189,207 +189,316 @@ fun TxStrip( verticalArrangement = Arrangement.spacedBy(10.dp), ) { // ---- Row 1: status + CAT chip ---- - val status = txStatusVisuals(isTransmitting, isTuning) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row( - modifier = Modifier.weight(1f, fill = false), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - PulseDot(color = if (status.listening) Signal else Accent) - Column { - Text( - text = stringResource(status.labelRes), - color = TextPrimary, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = InterFamily, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (status.listening) { - NextTxWindowLabel(slotMillis = slotMillis, txSlot = txSlot) - } - } - } - if (showCatChip) { - CatStatusChip(state = catState, onReconnect = onReconnectCat) - } - } + OperateStatusRow( + isTransmitting = isTransmitting, + isTuning = isTuning, + slotMillis = slotMillis, + txSlot = txSlot, + showCatChip = showCatChip, + catState = catState, + onReconnectCat = onReconnectCat, + ) // ---- Row 2: band & mode row (opens the Band & Mode sheet) ---- + BandModeRow(bandModeLabel = bandModeLabel, onOpenBandMode = onOpenBandMode) + + // ---- Row 3: Call CQ (+ MORE) · Hunt tile ---- + CqHuntRow( + actions = actions, + huntOptionLabel = huntOptionLabel, + cqModifier = cqModifier, + isFreeTextMode = isFreeTextMode, + fieldDayEnabled = fieldDayEnabled, + onCallCQ = onCallCQ, + onStop = onStop, + onOpenCqOptions = onOpenCqOptions, + onToggleHunt = onToggleHunt, + onOpenHuntOptions = onOpenHuntOptions, + ) + + // ---- Row 4: TX period segmented control · Tune · DX ---- + PeriodTuneDxRow( + txSlot = txSlot, + isActivated = isActivated, + isTransmitting = isTransmitting, + isTuning = isTuning, + tuneRemainingSec = tuneRemainingSec, + dxEnabled = dxEnabled, + onSelectTxPeriod = onSelectTxPeriod, + onToggleTune = onToggleTune, + onToggleDx = onToggleDx, + ) + + // ---- Inline TX volume slider (togglable from Settings) ---- + if (showVolumeSlider) { + TxVolumeRow( + txVolume = txVolume, + onVolumeChange = onVolumeChange, + onVolumeChangeFinished = onVolumeChangeFinished, + ) + } + } +} + +/** + * Row 1 — the status line (pulse dot + plain-language state, plus the "next transmit window" + * countdown while Listening) with the optional CAT chip on the right. Extracted so both the + * legacy [TxStrip] and the collapsing [OperateControlsDrawer] render an identical status line. + */ +@Composable +internal fun OperateStatusRow( + isTransmitting: Boolean, + isTuning: Boolean, + slotMillis: Long, + txSlot: Int, + showCatChip: Boolean, + catState: CatConnectionState, + onReconnectCat: () -> Unit, + modifier: Modifier = Modifier, +) { + val status = txStatusVisuals(isTransmitting, isTuning) + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { Row( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(BgSurface3) - .border(1.dp, Border, RoundedCornerShape(12.dp)) - .clickable { onOpenBandMode() } - .padding(horizontal = 14.dp), + modifier = Modifier.weight(1f, fill = false), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = stringResource(R.string.tx_band_mode_label), - color = TextFaint, - fontSize = 10.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = InterFamily, - letterSpacing = 0.8.sp, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { + PulseDot(color = if (status.listening) Signal else Accent) + Column { Text( - text = bandModeLabel, + text = stringResource(status.labelRes), color = TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, - fontFamily = GeistMonoFamily, + fontFamily = InterFamily, maxLines = 1, - softWrap = false, + overflow = TextOverflow.Ellipsis, ) - FT8AFIcons.ChevronDown(size = 14.dp, color = TextMuted, strokeWidth = 2f) + if (status.listening) { + NextTxWindowLabel(slotMillis = slotMillis, txSlot = txSlot) + } } } + if (showCatChip) { + CatStatusChip(state = catState, onReconnect = onReconnectCat) + } + } +} - // ---- Row 3: Call CQ (+ MORE) · Hunt tile ---- +/** Row 2 — the tappable Band & Mode card that opens the Band & Mode sheet. */ +@Composable +internal fun BandModeRow( + bandModeLabel: String, + onOpenBandMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(BgSurface3) + .border(1.dp, Border, RoundedCornerShape(12.dp)) + .clickable { onOpenBandMode() } + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.tx_band_mode_label), + color = TextFaint, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + letterSpacing = 0.8.sp, + ) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - val cqIsStop = actions.cqIsStop - val variantSubtitle = cqStripSubtitle(cqIsStop, isFreeTextMode, fieldDayEnabled, cqModifier) - val cqSubtitle = variantSubtitle - ?: if (!cqIsStop) stringResource(R.string.tx_call_cq_subtitle) else null - CallCqButton( - modifier = Modifier.weight(1.6f), - cqIsStop = cqIsStop, - cqDisabled = actions.cqDisabled, - subtitle = cqSubtitle, - onClick = { if (cqIsStop) onStop() else onCallCQ() }, - onOpenOptions = onOpenCqOptions, - optionsContentDescription = stringResource(R.string.tx_cq_options), - moreLabel = stringResource(R.string.tx_more), - ) - - HuntTile( - modifier = Modifier.weight(1f), - huntEnabled = actions.huntActive, - huntDisabled = actions.huntDisabled, - optionLabel = huntOptionLabel, - onToggle = onToggleHunt, - onOpenOptions = onOpenHuntOptions, - optionsContentDescription = stringResource(R.string.tx_hunt_options), + Text( + text = bandModeLabel, + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + maxLines = 1, + softWrap = false, ) + FT8AFIcons.ChevronDown(size = 14.dp, color = TextMuted, strokeWidth = 2f) } + } +} - // ---- Row 4: TX period segmented control · Tune · DX ---- - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, +/** Row 3 — the primary Call CQ button (+ MORE) alongside the Hunt tile. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun CqHuntRow( + actions: TxStripActionState, + huntOptionLabel: String, + cqModifier: String, + isFreeTextMode: Boolean, + fieldDayEnabled: Boolean, + onCallCQ: () -> Unit, + onStop: () -> Unit, + onOpenCqOptions: () -> Unit, + onToggleHunt: () -> Unit, + onOpenHuntOptions: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val cqIsStop = actions.cqIsStop + val variantSubtitle = cqStripSubtitle(cqIsStop, isFreeTextMode, fieldDayEnabled, cqModifier) + val cqSubtitle = variantSubtitle + ?: if (!cqIsStop) stringResource(R.string.tx_call_cq_subtitle) else null + CallCqButton( + modifier = Modifier.weight(1.6f), + cqIsStop = cqIsStop, + cqDisabled = actions.cqDisabled, + subtitle = cqSubtitle, + onClick = { if (cqIsStop) onStop() else onCallCQ() }, + onOpenOptions = onOpenCqOptions, + optionsContentDescription = stringResource(R.string.tx_cq_options), + moreLabel = stringResource(R.string.tx_more), + ) + + HuntTile( + modifier = Modifier.weight(1f), + huntEnabled = actions.huntActive, + huntDisabled = actions.huntDisabled, + optionLabel = huntOptionLabel, + onToggle = onToggleHunt, + onOpenOptions = onOpenHuntOptions, + optionsContentDescription = stringResource(R.string.tx_hunt_options), + ) + } +} + +/** Row 4 — the TX-period segmented control alongside the Tune and DX secondary buttons. */ +@Composable +internal fun PeriodTuneDxRow( + txSlot: Int, + isActivated: Boolean, + isTransmitting: Boolean, + isTuning: Boolean, + tuneRemainingSec: Int, + dxEnabled: Boolean, + onSelectTxPeriod: (Int) -> Unit, + onToggleTune: () -> Unit, + onToggleDx: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TxPeriodControl( + modifier = Modifier.weight(1.7f), + txSlot = txSlot, + onSelect = onSelectTxPeriod, + ) + val tuneEnabled = isTuning || (!isActivated && !isTransmitting) + SecondaryButton( + modifier = Modifier.weight(0.45f), + label = tuneChipLabel(stringResource(R.string.tune_button), isTuning, tuneRemainingSec), + active = isTuning, + enabled = tuneEnabled, + onClick = onToggleTune, + ) + SecondaryButton( + modifier = Modifier.weight(0.45f), + label = stringResource(R.string.tx_dx), + active = dxEnabled, + enabled = true, + onClick = onToggleDx, + ) + } +} + +/** The inline TX-volume slider (− / slider / +): togglable from Settings. */ +@Composable +internal fun TxVolumeRow( + txVolume: Int, + onVolumeChange: (Int) -> Unit, + onVolumeChangeFinished: () -> Unit, + modifier: Modifier = Modifier, +) { + val volumeDecrease = stringResource(R.string.tx_volume_decrease) + val volumeIncrease = stringResource(R.string.tx_volume_increase) + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + .background(BgSurface3) + .semantics { role = Role.Button; contentDescription = volumeDecrease } + .clickable { + onVolumeChange(clampVolume(txVolume, -5)) + onVolumeChangeFinished() + }, + contentAlignment = Alignment.Center, ) { - TxPeriodControl( - modifier = Modifier.weight(1.7f), - txSlot = txSlot, - onSelect = onSelectTxPeriod, - ) - val tuneEnabled = isTuning || (!isActivated && !isTransmitting) - SecondaryButton( - modifier = Modifier.weight(0.45f), - label = tuneChipLabel(stringResource(R.string.tune_button), isTuning, tuneRemainingSec), - active = isTuning, - enabled = tuneEnabled, - onClick = onToggleTune, - ) - SecondaryButton( - modifier = Modifier.weight(0.45f), - label = stringResource(R.string.tx_dx), - active = dxEnabled, - enabled = true, - onClick = onToggleDx, + Text( + text = "−", + color = TextMuted, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, ) } - // ---- Inline TX volume slider (togglable from Settings) ---- - val volumeDecrease = stringResource(R.string.tx_volume_decrease) - val volumeIncrease = stringResource(R.string.tx_volume_increase) - if (showVolumeSlider) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Box( - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)) - .background(BgSurface3) - .semantics { role = Role.Button; contentDescription = volumeDecrease } - .clickable { - onVolumeChange(clampVolume(txVolume, -5)) - onVolumeChangeFinished() - }, - contentAlignment = Alignment.Center, - ) { - Text( - text = "−", - color = TextMuted, - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - fontFamily = GeistMonoFamily, - ) - } - - IntSlider( - value = txVolume, - onValueChange = { v -> onVolumeChange(v.coerceIn(0, 100)) }, - onValueChangeFinished = onVolumeChangeFinished, - valueRange = 0f..100f, - modifier = Modifier.weight(1f), - thumbColor = Accent, - activeTrackColor = Accent, - ) - - Box( - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)) - .background(BgSurface3) - .semantics { role = Role.Button; contentDescription = volumeIncrease } - .clickable { - onVolumeChange(clampVolume(txVolume, 5)) - onVolumeChangeFinished() - }, - contentAlignment = Alignment.Center, - ) { - Text( - text = "+", - color = TextMuted, - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - fontFamily = GeistMonoFamily, - ) - } + IntSlider( + value = txVolume, + onValueChange = { v -> onVolumeChange(v.coerceIn(0, 100)) }, + onValueChangeFinished = onVolumeChangeFinished, + valueRange = 0f..100f, + modifier = Modifier.weight(1f), + thumbColor = Accent, + activeTrackColor = Accent, + ) - Text( - text = "${txVolume}%", - color = TextPrimary, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = GeistMonoFamily, - letterSpacing = 0.02.sp, - ) - } + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + .background(BgSurface3) + .semantics { role = Role.Button; contentDescription = volumeIncrease } + .clickable { + onVolumeChange(clampVolume(txVolume, 5)) + onVolumeChangeFinished() + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = "+", + color = TextMuted, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, + ) } + + Text( + text = "${txVolume}%", + color = TextPrimary, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.02.sp, + ) } } @@ -400,7 +509,7 @@ fun TxStrip( */ @OptIn(ExperimentalFoundationApi::class) @Composable -private fun CallCqButton( +internal fun CallCqButton( cqIsStop: Boolean, cqDisabled: Boolean, subtitle: String?, @@ -508,7 +617,7 @@ private fun CallCqButton( * running (Hunt and calling CQ are mutually exclusive). */ @Composable -private fun HuntTile( +internal fun HuntTile( huntEnabled: Boolean, huntDisabled: Boolean, optionLabel: String, @@ -605,7 +714,7 @@ private fun HuntChip( * 2nd (odd) transmit slot. [txSlot] 0 selects "1st (even)", 1 selects "2nd (odd)". */ @Composable -private fun TxPeriodControl( +internal fun TxPeriodControl( txSlot: Int, onSelect: (Int) -> Unit, modifier: Modifier = Modifier, @@ -684,7 +793,7 @@ private fun PeriodSegment( /** A small 52dp secondary button (Tune / DX): amber-tinted when active, muted otherwise. */ @Composable -private fun SecondaryButton( +internal fun SecondaryButton( label: String, active: Boolean, enabled: Boolean, @@ -726,7 +835,7 @@ private fun SecondaryButton( } @Composable -private fun PulseDot(color: Color) { +internal fun PulseDot(color: Color) { val infiniteTransition = rememberInfiniteTransition(label = "pulse") val pulseAlpha by infiniteTransition.animateFloat( initialValue = 0.4f, diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt index 4b59347af..e8de0b4c6 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt @@ -826,6 +826,23 @@ fun RadioAudioSettings( ) } SectionDivider() + run { + var drawer by remember { mutableStateOf(GeneralVariables.controlsDrawerEnabled) } + SettingsRow( + label = stringResource(R.string.settings_controls_drawer), + description = stringResource(R.string.settings_controls_drawer_desc), + toggle = drawer, + onToggleChange = { enabled -> + drawer = enabled + GeneralVariables.controlsDrawerEnabled = enabled + GeneralVariables.mutableControlsDrawerEnabled.postValue(enabled) + mainViewModel.databaseOpr.writeConfig( + "controlsDrawerEnabled", if (enabled) "1" else "0", null, + ) + }, + ) + } + SectionDivider() run { var perBand by remember { mutableStateOf(GeneralVariables.savePerBandOutputLevel) diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 331ec6e88..70f377113 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -694,6 +694,11 @@ Transmit audio level (hardware buttons ±5%) Show Volume Slider Display TX volume slider on main screen + Controls Drawer + Collapse the operate controls into a bottom sheet you pull up. Off shows the full control strip at all times. + Expand controls + Collapse controls + More controls Save Output Level per Band Remember the TX volume separately for each band and restore it on band change TX volume %1$d%% restored for %2$s diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/MeterProtectionControllerTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/MeterProtectionControllerTest.java index d0520c656..e15027b6f 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/MeterProtectionControllerTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/MeterProtectionControllerTest.java @@ -96,13 +96,24 @@ public void roundTrip_boundariesAreStable() { @Test public void shouldHalt_onlyWhenEnabledValidAndOverThreshold() { // Over threshold with protection on -> halt. - assertThat(MeterProtectionController.shouldHaltForSwr(150, true, 120)).isTrue(); + assertThat(MeterProtectionController.shouldHaltForSwr(150, true, 120, false)).isTrue(); // At/under threshold -> no halt (strictly greater-than). - assertThat(MeterProtectionController.shouldHaltForSwr(120, true, 120)).isFalse(); - assertThat(MeterProtectionController.shouldHaltForSwr(90, true, 120)).isFalse(); + assertThat(MeterProtectionController.shouldHaltForSwr(120, true, 120, false)).isFalse(); + assertThat(MeterProtectionController.shouldHaltForSwr(90, true, 120, false)).isFalse(); // Protection off -> never halt, even at high SWR. - assertThat(MeterProtectionController.shouldHaltForSwr(200, false, 120)).isFalse(); + assertThat(MeterProtectionController.shouldHaltForSwr(200, false, 120, false)).isFalse(); // No reading from the rig (-1) -> never halt. - assertThat(MeterProtectionController.shouldHaltForSwr(-1, true, 120)).isFalse(); + assertThat(MeterProtectionController.shouldHaltForSwr(-1, true, 120, false)).isFalse(); + } + + @Test + public void shouldHalt_neverWhileTuning() { + // A user tune deliberately keys a carrier to bring SWR down, so even an + // over-threshold reading with protection enabled must NOT halt while tuning + // — otherwise the operator is locked out of the retune they just started. + assertThat(MeterProtectionController.shouldHaltForSwr(150, true, 120, true)).isFalse(); + assertThat(MeterProtectionController.shouldHaltForSwr(255, true, 120, true)).isFalse(); + // The same reading DOES halt once the tune ends (tuning=false). + assertThat(MeterProtectionController.shouldHaltForSwr(150, true, 120, false)).isTrue(); } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java index 7cede35b7..20f040976 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java @@ -58,10 +58,10 @@ public void catToNormalizedSwr_alignsWithHaltThresholdScale() { public void catToNormalizedSwr_tripsHaltAboveDefaultThreshold() { // Default swr-halt threshold is 120 (~3.0:1); a reading of 23 (~8.7:1) must halt. int normalized = DiscoveryTX500Rig.tx500CatToNormalizedSwr(23); - assertThat(MeterProtectionController.shouldHaltForSwr(normalized, true, 120)).isTrue(); + assertThat(MeterProtectionController.shouldHaltForSwr(normalized, true, 120, false)).isTrue(); // A low reading (cat 3 ~= 2.0:1) must NOT halt at the default threshold. int low = DiscoveryTX500Rig.tx500CatToNormalizedSwr(3); - assertThat(MeterProtectionController.shouldHaltForSwr(low, true, 120)).isFalse(); + assertThat(MeterProtectionController.shouldHaltForSwr(low, true, 120, false)).isFalse(); } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/AudioChannelSelectTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/AudioChannelSelectTest.java index 8f560b02e..19ed935a4 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/wave/AudioChannelSelectTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/AudioChannelSelectTest.java @@ -140,7 +140,7 @@ public void foldToMono_clampsSampleCountToTheBufferLength() { @Test public void needsStereoPlayback_onlyForASingleSide() { // BOTH must stay a mono open: that path is the historical TX pipeline and - // is not worth disturbing (see the CLAUDE.md TX audio hazards). + // is not worth disturbing (see the AGENTS.md TX audio hazards). assertThat(AudioChannelSelect.needsStereoPlayback(AudioChannelSelect.BOTH)).isFalse(); assertThat(AudioChannelSelect.needsStereoPlayback(AudioChannelSelect.LEFT)).isTrue(); assertThat(AudioChannelSelect.needsStereoPlayback(AudioChannelSelect.RIGHT)).isTrue(); diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt new file mode 100644 index 000000000..39a036eb8 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt @@ -0,0 +1,70 @@ +package radio.ks3ckc.ft8af.ui.components + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [drawerTargetExpanded] — the pure gesture rule deciding whether the + * operate-controls drawer (design 3a/3b) settles open or collapsed after a drag on its handle. + * No Android/Compose runtime needed. Sign convention: negative dy = upward drag. + */ +class OperateControlsDrawerTest { + + private val threshold = 48f + + @Test + fun `upward drag past threshold from collapsed expands`() { + assertThat(drawerTargetExpanded(currentlyExpanded = false, dragDy = -60f, thresholdPx = threshold)) + .isTrue() + } + + @Test + fun `upward drag exactly at threshold from collapsed expands`() { + assertThat(drawerTargetExpanded(currentlyExpanded = false, dragDy = -48f, thresholdPx = threshold)) + .isTrue() + } + + @Test + fun `small upward drag from collapsed stays collapsed`() { + assertThat(drawerTargetExpanded(currentlyExpanded = false, dragDy = -20f, thresholdPx = threshold)) + .isFalse() + } + + @Test + fun `downward drag from collapsed stays collapsed`() { + assertThat(drawerTargetExpanded(currentlyExpanded = false, dragDy = 200f, thresholdPx = threshold)) + .isFalse() + } + + @Test + fun `downward drag past threshold from expanded collapses`() { + assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = 60f, thresholdPx = threshold)) + .isFalse() + } + + @Test + fun `downward drag exactly at threshold from expanded collapses`() { + assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = 48f, thresholdPx = threshold)) + .isFalse() + } + + @Test + fun `small downward drag from expanded stays expanded`() { + assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = 20f, thresholdPx = threshold)) + .isTrue() + } + + @Test + fun `upward drag from expanded stays expanded`() { + assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = -200f, thresholdPx = threshold)) + .isTrue() + } + + @Test + fun `no drag leaves state unchanged`() { + assertThat(drawerTargetExpanded(currentlyExpanded = false, dragDy = 0f, thresholdPx = threshold)) + .isFalse() + assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = 0f, thresholdPx = threshold)) + .isTrue() + } +} diff --git a/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj b/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj index 741341142..08939892a 100644 --- a/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj +++ b/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 7FC3449767DCA16F70133366 /* DecodeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C79C0E0096ADDC018120C361 /* DecodeScreen.swift */; }; 7FF97BE16C182502CA0E4F40 /* RigModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB78DA6983B10ABE10319476 /* RigModelTests.swift */; }; 80F7FFDDEE3AEE3E706C4A2B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 076A186C88F9B5A2FFAAB073 /* Assets.xcassets */; }; + 882AA28CDD50C648EB0AB7C5 /* ExceptionCatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 05A1E61AD575F93424F65731 /* ExceptionCatcher.m */; }; 8DA0D562E25925F56B638B00 /* GridDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E429D8A3556534F05374F12A /* GridDistance.swift */; }; 8EA75A450404FB948C0A6CDC /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504C9AD4C6667267DFCABEF9 /* SettingsScreen.swift */; }; 8EF984B5816F012A2DF93FDF /* OnlineLogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D131B05E27A96EC4BEB28782 /* OnlineLogService.swift */; }; @@ -64,6 +65,7 @@ B4122A07A833C6746F51D5D6 /* PotaService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E27EE4248BF38A15E0F3B2D /* PotaService.swift */; }; B8FD5A2DF3B2A89740B3CB73 /* WorldMapCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDDB723455CB7AE2BB68EF0 /* WorldMapCanvas.swift */; }; BCEB9305C3EA84C758FAFBA7 /* AzimuthalMapCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB943F6B81F9D36BA85504A3 /* AzimuthalMapCanvas.swift */; }; + C1B544AEB67D088C227528EC /* TxConnectionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46834CB53DF42AA73774F7DA /* TxConnectionStateTests.swift */; }; CE1ECA0C5BB350A1EDDA7018 /* SlotTimerBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800081E79EC795EBDE2E077E /* SlotTimerBar.swift */; }; CF1120C958F019240FB25A25 /* Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0CA452882C4980BED953078 /* Typography.swift */; }; D36ECEB73B847787DE373072 /* SpectrumStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96D9C69CFC1480E6A5B64B5 /* SpectrumStrip.swift */; }; @@ -97,6 +99,7 @@ /* Begin PBXFileReference section */ 0244EBDFC2ADDAE77418FF53 /* ActiveQsoPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveQsoPanel.swift; sourceTree = ""; }; 0290AFE608DC2FC9E1A83E16 /* QsoSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QsoSheet.swift; sourceTree = ""; }; + 05A1E61AD575F93424F65731 /* ExceptionCatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ExceptionCatcher.m; sourceTree = ""; }; 076A186C88F9B5A2FFAAB073 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 0FD3B4836E62F9DC32FAB0DE /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; 11DF7123A737FE9F5236677F /* DecodeRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeRow.swift; sourceTree = ""; }; @@ -105,12 +108,14 @@ 1E706B1D26B550FE4D798B68 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; 29B5962B70F4510D76A5BC0A /* TxPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TxPlayerService.swift; sourceTree = ""; }; 2A9EBD1E2440476261F01908 /* ClockSyncIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClockSyncIndicator.swift; sourceTree = ""; }; + 2B7D37874EDD64500E81BBD8 /* ExceptionCatcher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ExceptionCatcher.h; sourceTree = ""; }; 2CFCB3285583184390D9E9BF /* AboutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutScreen.swift; sourceTree = ""; }; 33CCA018AEEE169EDCA338FD /* LogbookAwards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbookAwards.swift; sourceTree = ""; }; 37860F113FBD71CA51712822 /* FT8AFTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FT8AFTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B94D7FBAC132C5854796F79 /* AudioRouteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteController.swift; sourceTree = ""; }; 4217D8419318D6A336873605 /* AudioRoutePickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoutePickerButton.swift; sourceTree = ""; }; 467569ADBF392233E7AA71DC /* TimeSyncSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSyncSettings.swift; sourceTree = ""; }; + 46834CB53DF42AA73774F7DA /* TxConnectionStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TxConnectionStateTests.swift; sourceTree = ""; }; 47F0A25416CDB1102C9AD749 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; 4A8FFDE0644205A9AE050BC0 /* BlockedCallsignsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedCallsignsSettings.swift; sourceTree = ""; }; 4B08B1AA6951B99CEFAA521F /* TransmissionSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmissionSettings.swift; sourceTree = ""; }; @@ -167,6 +172,7 @@ E0CA452882C4980BED953078 /* Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typography.swift; sourceTree = ""; }; E429D8A3556534F05374F12A /* GridDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridDistance.swift; sourceTree = ""; }; F287F167508A8E4114F44116 /* FFTProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FFTProcessor.swift; sourceTree = ""; }; + F2D8A723CB45300FEDE7301B /* FT8AF-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FT8AF-Bridging-Header.h"; sourceTree = ""; }; F2EBE7CC0F661A6451447578 /* PttModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PttModeTests.swift; sourceTree = ""; }; F3C698656C5709343F4AEEF0 /* FrequencyPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequencyPickerSheet.swift; sourceTree = ""; }; FA66E8CE63C02CC35A2BC010 /* PotaActivationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotaActivationStore.swift; sourceTree = ""; }; @@ -326,6 +332,7 @@ children = ( BF21F5D95598018B8A46ED8D /* AppState.swift */, 076A186C88F9B5A2FFAAB073 /* Assets.xcassets */, + F2D8A723CB45300FEDE7301B /* FT8AF-Bridging-Header.h */, 6F1E760AEB31EF3855951728 /* FT8AFApp.swift */, BE0DCBA7E647113F08F32531 /* Info.plist */, 7DCF29540DF6FBE5F4ECD5FC /* Components */, @@ -357,6 +364,7 @@ 7DB508B1F2E7EBB815B8C9B2 /* AudioCaptureSessionOptionsTests.swift */, F2EBE7CC0F661A6451447578 /* PttModeTests.swift */, BB78DA6983B10ABE10319476 /* RigModelTests.swift */, + 46834CB53DF42AA73774F7DA /* TxConnectionStateTests.swift */, ); path = FT8AFTests; sourceTree = ""; @@ -378,6 +386,8 @@ children = ( C42FB097015693FF14B27553 /* AudioCaptureService.swift */, 3B94D7FBAC132C5854796F79 /* AudioRouteController.swift */, + 2B7D37874EDD64500E81BBD8 /* ExceptionCatcher.h */, + 05A1E61AD575F93424F65731 /* ExceptionCatcher.m */, F287F167508A8E4114F44116 /* FFTProcessor.swift */, 47F0A25416CDB1102C9AD749 /* KeychainStore.swift */, 4E09812700CD93E6187CC4BC /* LiveEngine.swift */, @@ -527,6 +537,7 @@ 3BA2BC0EFAF62534D39C3A4A /* DecodeFilterSettings.swift in Sources */, F3CB49EBF218C3BB9FAE02D6 /* DecodeRow.swift in Sources */, 7FC3449767DCA16F70133366 /* DecodeScreen.swift in Sources */, + 882AA28CDD50C648EB0AB7C5 /* ExceptionCatcher.m in Sources */, EEA0D2F1B9A7E0345F68E909 /* ExportLogSheet.swift in Sources */, 771EC057824C1F3698C6DC9B /* FFTProcessor.swift in Sources */, EC59A75E444DC6AB44913CB7 /* FT8AFApp.swift in Sources */, @@ -583,6 +594,7 @@ 434629940DB32DDFFA313E4F /* AudioCaptureSessionOptionsTests.swift in Sources */, 1418BC3ED37E2C61C4A09ED1 /* PttModeTests.swift in Sources */, 7FF97BE16C182502CA0E4F40 /* RigModelTests.swift in Sources */, + C1B544AEB67D088C227528EC /* TxConnectionStateTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -626,6 +638,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = radio.ks3ckc.ft8af; SDKROOT = iphoneos; + SWIFT_OBJC_BRIDGING_HEADER = "FT8AF/FT8AF-Bridging-Header.h"; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -716,6 +729,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = radio.ks3ckc.ft8af; SDKROOT = iphoneos; + SWIFT_OBJC_BRIDGING_HEADER = "FT8AF/FT8AF-Bridging-Header.h"; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/ios/FT8AF/FT8AF/Engine/ExceptionCatcher.h b/ios/FT8AF/FT8AF/Engine/ExceptionCatcher.h new file mode 100644 index 000000000..1b099894d --- /dev/null +++ b/ios/FT8AF/FT8AF/Engine/ExceptionCatcher.h @@ -0,0 +1,15 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Runs `block` inside an Obj-C `@try/@catch` and returns the raised +/// `NSException`, or `nil` if none was raised. +/// +/// Swift cannot catch Obj-C exceptions natively (`try` only handles Swift +/// `Error`), so an AVFAudio call that raises one — e.g. +/// `AVAudioPlayerNode.play()` when the engine graph is torn down by a +/// configuration change mid-call — aborts the whole process. Wrapping that call +/// in this shim degrades the abort into a recoverable `nil`/exception result. +NSException *_Nullable ft8af_catchException(void (^_Nonnull block)(void)); + +NS_ASSUME_NONNULL_END diff --git a/ios/FT8AF/FT8AF/Engine/ExceptionCatcher.m b/ios/FT8AF/FT8AF/Engine/ExceptionCatcher.m new file mode 100644 index 000000000..5696e2f4a --- /dev/null +++ b/ios/FT8AF/FT8AF/Engine/ExceptionCatcher.m @@ -0,0 +1,10 @@ +#import "ExceptionCatcher.h" + +NSException *_Nullable ft8af_catchException(void (^_Nonnull block)(void)) { + @try { + block(); + return nil; + } @catch (NSException *exception) { + return exception; + } +} diff --git a/ios/FT8AF/FT8AF/Engine/LiveEngine.swift b/ios/FT8AF/FT8AF/Engine/LiveEngine.swift index 612fa5661..edd5c085b 100644 --- a/ios/FT8AF/FT8AF/Engine/LiveEngine.swift +++ b/ios/FT8AF/FT8AF/Engine/LiveEngine.swift @@ -938,7 +938,7 @@ final class LiveEngine { /// boundary. The slack is the *physical* `TxTiming.defaultSlackMs` /// constant (15000 − 12640 = 2360 ms) — never the user setting: a smaller /// slack clips the leading Costas sync of ON-TIME transmissions and makes - /// them audible but undecodable (see CLAUDE.md, TX pipeline gotcha #2). + /// them audible but undecodable (see AGENTS.md, TX pipeline gotcha #2). /// The late-start-tolerance setting is a SKIP threshold instead: when more /// than that much leading audio would be clipped, the slot is skipped. private func beginTxPlayback(message: String, samples: [Float], profile: ModeProfile) { diff --git a/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift b/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift index 4cc77db4b..a9faf3455 100644 --- a/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift +++ b/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift @@ -1,6 +1,37 @@ import AVFoundation import FT8DSP +/// Pure connection-lifecycle state for the TX player node, extracted from +/// `TxPlayerService` so the (untestable) `AVAudioEngine` calls stay a thin +/// wrapper around testable decisions. +/// +/// The node is *attached* to the engine exactly once, but its *connection* +/// into the mixer graph is torn down by the engine on every +/// `AVAudioEngineConfigurationChange` (a USB audio interface such as a DigiRig +/// attaching/detaching, or any output-route change). Calling +/// `AVAudioPlayerNode.play()` on a node whose connection was invalidated raises +/// an uncaught Obj-C `NSException` inside AVFAudio and aborts the whole app — +/// which is exactly what happened at TX time after a DigiRig was plugged in. +/// Tracking attach and connect separately lets `play()` re-establish the +/// connection lazily after a config change instead of trusting a stale flag. +struct TxConnectionState { + private(set) var isAttached = false + private(set) var isConnected = false + + /// The node must be attached to the engine before it can be connected. + var needsAttach: Bool { !isAttached } + /// The node must be (re)connected + the converter rebuilt before playback. + var needsConnect: Bool { !isConnected } + + mutating func markAttached() { isAttached = true } + mutating func markConnected() { isConnected = true } + + /// A configuration change leaves the node attached but invalidates every + /// connection in the graph, so the next `play()` must reconnect and rebuild + /// the format converter against the (possibly new) mixer output format. + mutating func invalidateConnection() { isConnected = false } +} + /// Plays a mono 12 kHz FT8 waveform through the device speaker using an /// `AVAudioPlayerNode` attached to a shared `AVAudioEngine`. The TX scheduler /// calls `play(_:)` at the correct slot time; the service handles sample-rate @@ -8,14 +39,16 @@ import FT8DSP /// /// Node attachment is deferred to the first `play()` call so that the output /// hardware format has stabilized (on Simulator the output node may report -/// 0 Hz at engine-start time, which crashes `connect()`). +/// 0 Hz at engine-start time, which crashes `connect()`). The connection is +/// re-established after any `AVAudioEngineConfigurationChange` — see +/// `TxConnectionState`. final class TxPlayerService: @unchecked Sendable { private let playerNode = AVAudioPlayerNode() private var engine: AVAudioEngine? private var outputConverter: AVAudioConverter? private let lock = NSLock() private var _isPlaying = false - private var isAttached = false + private var conn = TxConnectionState() /// The 12 kHz mono format used by FT8Encoder output. private let ft8Format = AVAudioFormat( @@ -30,32 +63,73 @@ final class TxPlayerService: @unchecked Sendable { return _isPlaying } - /// Store the engine reference for deferred attachment. Does NOT connect - /// the player node yet — that happens lazily in `ensureAttached()`. + /// Store the engine reference for deferred attachment and observe + /// configuration changes so a route change (e.g. a DigiRig attaching) + /// forces the player node to reconnect before the next TX. Does NOT connect + /// the player node yet — that happens lazily in `ensureConnected()`. func configure(engine: AVAudioEngine) { self.engine = engine + // `configure` is called on every LiveEngine start (stop→start reuses the + // same long-lived engine), so drop any prior registration first — a bare + // addObserver would stack a new observer each cycle and fire the handler + // N times per config change. + NotificationCenter.default.removeObserver( + self, name: .AVAudioEngineConfigurationChange, object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleConfigChange), + name: .AVAudioEngineConfigurationChange, + object: engine + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + /// The engine tore down its graph (route/config change). The node stays + /// attached but its connection is gone; drop the cached converter so the + /// next `play()` reconnects against the current mixer format instead of + /// keying a disconnected node (which aborts the app). + @objc private func handleConfigChange() { + lock.lock() + conn.invalidateConnection() + outputConverter = nil + lock.unlock() } - /// Attach and connect the player node, creating the format converter. - /// Returns `false` if the hardware output format is invalid (e.g. 0 Hz - /// on Simulator at startup) — the caller should skip playback. - private func ensureAttached() -> Bool { - if isAttached { return outputConverter != nil } - guard let engine else { return false } + /// Attach (once) and connect the player node, creating the format + /// converter. Reconnects and rebuilds the converter when a prior config + /// change invalidated the connection. Returns the converter, or `nil` if the + /// hardware output format is invalid (e.g. 0 Hz on Simulator at startup) — + /// the caller should skip playback. Returning the converter directly (rather + /// than letting `play()` re-read `outputConverter`) keeps the read under the + /// lock, so a concurrent `handleConfigChange` can't nil it between the check + /// and the use. + private func ensureConnected() -> AVAudioConverter? { + lock.lock(); defer { lock.unlock() } + if !conn.needsConnect { return outputConverter } + guard let engine else { return nil } // Use the mixer's output format — on real devices this is the // hardware rate (e.g. 48 kHz stereo); on Simulator it becomes valid - // once the engine is running. + // once the engine is running. After a config change it may be a + // different rate (e.g. the DigiRig's), so it must be re-read here. let mixerFormat = engine.mainMixerNode.outputFormat(forBus: 0) guard mixerFormat.sampleRate > 0, mixerFormat.channelCount > 0 else { - return false + return nil } - engine.attach(playerNode) + if conn.needsAttach { + engine.attach(playerNode) + conn.markAttached() + } engine.connect(playerNode, to: engine.mainMixerNode, format: mixerFormat) outputConverter = AVAudioConverter(from: ft8Format, to: mixerFormat) - isAttached = true - return outputConverter != nil + guard outputConverter != nil else { return nil } + conn.markConnected() + return outputConverter } /// Schedule and play `samples` (mono Float32 at 12 kHz). Interrupts any @@ -72,7 +146,7 @@ final class TxPlayerService: @unchecked Sendable { // interruption, Simulator startup) — AVAudioPlayerNode.play() on a // stopped engine logs "Cannot play yet!" and the TX is silent, and a // stopped engine can also report an invalid mixer format, which would - // make the deferred attach below bail. Restart it first. + // make the deferred connect below bail. Restart it first. if let engine, !engine.isRunning { do { try engine.start() @@ -82,7 +156,10 @@ final class TxPlayerService: @unchecked Sendable { } } - guard ensureAttached(), let converter = outputConverter else { + // Re-establish the node connection if a config change invalidated it. + // Skipping this is what aborts the app: play() on a disconnected node + // raises an uncaught NSException in AVFAudio. + guard let converter = ensureConnected() else { completion?() return false } @@ -127,14 +204,31 @@ final class TxPlayerService: @unchecked Sendable { _isPlaying = true lock.unlock() - playerNode.stop() - playerNode.scheduleBuffer(outputBuffer, at: nil, options: .interrupts) { [weak self] in - self?.lock.lock() - self?._isPlaying = false - self?.lock.unlock() + // A config change can tear down the node's connection on another thread + // in the window between `ensureConnected()` and here; `play()` on a + // disconnected node then raises an uncaught Obj-C NSException that aborts + // the app. Swift's `try` can't catch it, so run the keying through an + // Obj-C @try shim and, on failure, invalidate the connection so the next + // TX rebuilds it. The audio is lost for this slot but the app survives. + let raised = ft8af_catchException { [self] in + playerNode.stop() + playerNode.scheduleBuffer(outputBuffer, at: nil, options: .interrupts) { [weak self] in + self?.lock.lock() + self?._isPlaying = false + self?.lock.unlock() + completion?() + } + playerNode.play() + } + if raised != nil { + lock.lock() + _isPlaying = false + conn.invalidateConnection() + outputConverter = nil + lock.unlock() completion?() + return false } - playerNode.play() return true } diff --git a/ios/FT8AF/FT8AF/FT8AF-Bridging-Header.h b/ios/FT8AF/FT8AF/FT8AF-Bridging-Header.h new file mode 100644 index 000000000..232a475b0 --- /dev/null +++ b/ios/FT8AF/FT8AF/FT8AF-Bridging-Header.h @@ -0,0 +1,6 @@ +// +// FT8AF-Bridging-Header.h +// Exposes the app's Objective-C helpers to Swift. +// + +#import "ExceptionCatcher.h" diff --git a/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift b/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift new file mode 100644 index 000000000..be37b637a --- /dev/null +++ b/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift @@ -0,0 +1,77 @@ +import XCTest +@testable import FT8AF + +/// Covers the connection-lifecycle decisions that guard `TxPlayerService.play`. +/// The regression these lock down: after an `AVAudioEngineConfigurationChange` +/// (e.g. a DigiRig USB interface attaching) the engine tears down the player +/// node's connection, and playing on a disconnected node aborts the app. The +/// state must report `needsConnect` again after such a change while keeping the +/// node attached, so `play()` reconnects instead of keying a dead node. +/// +/// Assertions use `needsAttach`/`needsConnect` — the exact flags +/// `ensureConnected()` branches on — so the tests exercise the production paths. +final class TxConnectionStateTests: XCTestCase { + func testFreshStateNeedsAttachAndConnect() { + let s = TxConnectionState() + XCTAssertTrue(s.needsAttach) + XCTAssertTrue(s.needsConnect) + } + + func testAfterAttachAndConnectNeedsNothing() { + var s = TxConnectionState() + s.markAttached() + s.markConnected() + XCTAssertFalse(s.needsAttach) + XCTAssertFalse(s.needsConnect) + } + + func testAttachedButNotConnectedStillNeedsConnect() { + var s = TxConnectionState() + s.markAttached() + XCTAssertFalse(s.needsAttach) + XCTAssertTrue(s.needsConnect) + } + + /// The core regression: a config change must invalidate the connection but + /// leave the node attached, so the next play reconnects without re-attaching. + func testConfigChangeInvalidatesConnectionButKeepsAttachment() { + var s = TxConnectionState() + s.markAttached() + s.markConnected() + + s.invalidateConnection() + + XCTAssertFalse(s.needsAttach, "node stays attached across a config change") + XCTAssertTrue(s.needsConnect, "connection must be rebuilt after a config change") + } + + func testReconnectAfterConfigChangeClearsNeedsConnect() { + var s = TxConnectionState() + s.markAttached() + s.markConnected() + s.invalidateConnection() + + // Simulate ensureConnected()'s reconnect path: no re-attach needed, + // just a fresh connection. + XCTAssertFalse(s.needsAttach) + s.markConnected() + + XCTAssertFalse(s.needsConnect) + } + + /// Repeated config changes (unplug/replug) must keep forcing a reconnect, + /// never latching into a stale connected state. + func testRepeatedConfigChangesKeepRequiringReconnect() { + var s = TxConnectionState() + s.markAttached() + s.markConnected() + + for _ in 0..<5 { + s.invalidateConnection() + XCTAssertTrue(s.needsConnect) + XCTAssertFalse(s.needsAttach) + s.markConnected() + XCTAssertFalse(s.needsConnect) + } + } +} diff --git a/ios/FT8AF/project.yml b/ios/FT8AF/project.yml index 24cf4fbbc..44f4da328 100644 --- a/ios/FT8AF/project.yml +++ b/ios/FT8AF/project.yml @@ -50,6 +50,9 @@ targets: base: SWIFT_VERSION: "5.9" PRODUCT_BUNDLE_IDENTIFIER: radio.ks3ckc.ft8af + # Exposes ExceptionCatcher.h so Swift can catch the Obj-C NSException + # that AVAudioPlayerNode.play() raises during a mid-call config change. + SWIFT_OBJC_BRIDGING_HEADER: FT8AF/FT8AF-Bridging-Header.h dependencies: - package: FT8AFKit product: FT8DSP diff --git a/ios/FT8AFKit/Sources/FT8Engine/TxTiming.swift b/ios/FT8AFKit/Sources/FT8Engine/TxTiming.swift index a773d2df6..043c4a576 100644 --- a/ios/FT8AFKit/Sources/FT8Engine/TxTiming.swift +++ b/ios/FT8AFKit/Sources/FT8Engine/TxTiming.swift @@ -7,7 +7,7 @@ import Foundation /// transmission still ends on the cycle boundary — but ONLY the portion past /// the slack. Clipping anything on an on-time start (e.g. a naive /// `msIntoCycle % 15000`) chops the leading Costas sync array and produces an -/// audible-but-undecodable signal (see project CLAUDE.md, "FT8 TX audio +/// audible-but-undecodable signal (see project AGENTS.md, "FT8 TX audio /// pipeline" gotcha #2, fixed on Android in PR #93). public enum TxTiming { /// Slack between the 12.64 s FT8 waveform and the 15 s cycle. diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/TxTimingTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/TxTimingTests.swift index 8ba1b6a55..9aaa81665 100644 --- a/ios/FT8AFKit/Tests/FT8EngineTests/TxTimingTests.swift +++ b/ios/FT8AFKit/Tests/FT8EngineTests/TxTimingTests.swift @@ -8,7 +8,7 @@ final class TxTimingTests: XCTestCase { XCTAssertEqual(TxTiming.defaultSlackMs, 2360) } - // The load-bearing rule (CLAUDE.md TX pipeline gotcha #2): a normal + // The load-bearing rule (AGENTS.md TX pipeline gotcha #2): a normal // on-time TX firing a few hundred ms into the cycle must clip NOTHING — // that is where the leading Costas sync lives. func testOnTimeStartClipsNothing() {