diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index f9fa0d1e..68b89a56 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -2,11 +2,11 @@ name: Build & Release APK on: push: - branches: [main, Dev] + branches: [main, dev] tags: - 'v*' pull_request: - branches: [main] + branches: [main, dev] permissions: contents: write @@ -56,7 +56,7 @@ jobs: build: name: Build APK needs: test - # Run when tests pass (PR), or when tests were skipped (push to main/Dev/tags). + # Run when tests pass (PR), or when tests were skipped (push to main/dev/tags). # Block only on actual test failure or cancellation. if: ${{ always() && needs.test.result != 'failure' && needs.test.result != 'cancelled' }} runs-on: ubuntu-latest @@ -74,12 +74,19 @@ jobs: id: tag run: | set -euo pipefail + # Three release lanes (everything else is build-only): + # v* tag push -> production-named release, NOT prerelease + # push to main -> auto-bumped v* tag, NOT prerelease + # push to dev -> dev- tag, PRERELEASE + # Prereleases are still uploaded to Play internal but with a + # distinct releaseName so they're easy to tell apart from the + # v*-named candidates we'd actually promote to production. if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then - # Manual tag push — release as that tag echo "release_tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" elif [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/main" ]]; then - # Push to main — auto-bump patch from latest v* tag + # Auto-bump patch from latest v* tag. latest=$(git tag --list 'v*' --sort=-v:refname | head -n1) if [[ -z "$latest" ]]; then new_tag="v0.1" @@ -93,10 +100,21 @@ jobs: echo "Latest tag: ${latest:-} -> new tag: $new_tag" echo "release_tag=$new_tag" >> "$GITHUB_OUTPUT" echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + elif [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/dev" ]]; then + # dev pushes get a run-numbered tag and are flagged as prereleases. + # Distinct prefix so they sort separately from v* in GitHub releases + # and so the Play Console releaseName disambiguates them from + # main-branch release candidates. + new_tag="dev-${GITHUB_RUN_NUMBER}" + echo "release_tag=$new_tag" >> "$GITHUB_OUTPUT" + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" else - # PR or Dev push — build only, no release + # PR or other branch push — build only, no release echo "release_tag=" >> "$GITHUB_OUTPUT" echo "should_release=false" >> "$GITHUB_OUTPUT" + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" fi - name: Set up JDK 17 @@ -186,6 +204,10 @@ jobs: tag_name: ${{ steps.tag.outputs.release_tag }} files: ft8cn/app/build/outputs/apk/release/FT8CN-*.apk generate_release_notes: true + # dev-* releases are flagged as prerelease so they sort under the + # latest v* and don't get picked up by downstream tooling that + # looks for "latest release". + prerelease: ${{ steps.tag.outputs.is_prerelease == 'true' }} append_body: true body: | diff --git a/.github/workflows/main-gate.yml b/.github/workflows/main-gate.yml new file mode 100644 index 00000000..8e8f6621 --- /dev/null +++ b/.github/workflows/main-gate.yml @@ -0,0 +1,31 @@ +name: Main branch source gate + +# Enforces release discipline: PRs targeting main must come from `dev`. +# Feature branches are expected to land on dev first; only dev → main is +# allowed as the "promote to production" step, gated by branch protection +# requiring this status check. +# +# To enforce it: GitHub → Settings → Branches → Branch protection rule for +# `main` → Require status checks to pass → add "Main branch source gate / +# enforce-source-is-dev" to the required list. + +on: + pull_request: + branches: [main] + +jobs: + enforce-source-is-dev: + name: enforce-source-is-dev + runs-on: ubuntu-latest + steps: + - name: Check PR head branch + env: + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + if [[ "${HEAD_REF}" != "dev" ]]; then + echo "::error title=PR source must be dev::PRs to main are only allowed from the dev branch. Source branch: ${HEAD_REF}" + echo "Land your changes on dev first (PR from your feature branch into dev), then open dev → main." + exit 1 + fi + echo "OK — PR source is dev." diff --git a/README.md b/README.md index 1fe54dbf..e9ae7849 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ **FT8 on Android — modernized.** +🌐 **[ft8af.app](https://ft8af.app)** + A fork of [FT8CN](https://github.com/N0BOY/FT8CN) that takes the excellent original and brings it forward: a Jetpack Compose UI, full English localization, dozens of bug fixes, and a pile of new operating features built for real on-the-air use. Run FT8 natively on your Android phone or tablet, drive your radio over USB CAT, decode the band, and work the world from anywhere. diff --git a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java index 07c55cd3..7a9fa230 100644 --- a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java +++ b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java @@ -56,7 +56,7 @@ public class GeneralVariables { public static int usbAudioOutputProductId = 0; public static MutableLiveData mutableVolumePercent = new MutableLiveData<>(); - public static float volumePercent = 0.5f;//Audio playback volume, as a percentage + public static float volumePercent = 0.8f;//Audio playback volume, as a percentage public static int flexMaxRfPower = 10;//Flex radio max transmit power public static int flexMaxTunePower = 10;//Flex radio max tune power diff --git a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java index e66fdd68..9a6c2461 100644 --- a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java +++ b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java @@ -419,12 +419,25 @@ private void playFT8Signal(Ft8Message msg) { return; } - // USB audio output path + // USB audio output path. Logged so a dev-screen reader can see + // which branch was taken — the difference between TX going out + // the USB radio (this branch) vs. Android's default sink (the + // AudioTrack branch below) was the whole reason for #84, and we + // currently have no way to tell from debug.log which one fired. + GeneralVariables.fileLog(String.format( + "playFT8Signal: TX path branch: audioOutputDeviceId=%d " + + "usbAudioOutputVidPid=%04X:%04X", + GeneralVariables.audioOutputDeviceId, + GeneralVariables.usbAudioOutputVendorId, + GeneralVariables.usbAudioOutputProductId)); if (GeneralVariables.audioOutputDeviceId == -1 && GeneralVariables.usbAudioOutputVendorId != 0) { + GeneralVariables.fileLog("playFT8Signal: using USB audio (direct) output"); playViaUsbAudio(buffer); return; } + GeneralVariables.fileLog( + "playFT8Signal: using AudioTrack output (Android default sink)"); Log.d(TAG, String.format("playFT8Signal: Preparing sound card playback... bit depth: %s, sample rate: %d" , GeneralVariables.audioOutput32Bit ? "Float32" : "Int16" @@ -506,14 +519,15 @@ public void onPeriodicNotification(AudioTrack audioTrack) { * Play FT8 signal through USB audio device. */ private void playViaUsbAudio(float[] buffer) { - Log.d(TAG, String.format("playFT8Signal: USB audio output, VID=%04X PID=%04X, samples=%d, rate=%d", + GeneralVariables.fileLog(String.format( + "playViaUsbAudio: start, VID=%04X PID=%04X samples=%d rate=%d", GeneralVariables.usbAudioOutputVendorId, GeneralVariables.usbAudioOutputProductId, buffer.length, GeneralVariables.audioSampleRate)); Context context = GeneralVariables.getMainContext(); if (context == null) { - Log.e(TAG, "No context for USB audio"); + GeneralVariables.fileLog("playViaUsbAudio: ABORT no main context"); afterPlayAudio(); return; } @@ -522,38 +536,56 @@ private void playViaUsbAudio(float[] buffer) { GeneralVariables.usbAudioOutputVendorId, GeneralVariables.usbAudioOutputProductId); if (device == null) { - Log.e(TAG, "USB audio output device not found"); + GeneralVariables.fileLog(String.format( + "playViaUsbAudio: ABORT USB audio output device not found " + + "by VID:PID %04X:%04X", + GeneralVariables.usbAudioOutputVendorId, + GeneralVariables.usbAudioOutputProductId)); afterPlayAudio(); return; } UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); - if (usbManager == null || !usbManager.hasPermission(device)) { - Log.e(TAG, "No USB permission for audio output device"); + if (usbManager == null) { + GeneralVariables.fileLog("playViaUsbAudio: ABORT UsbManager is null"); + afterPlayAudio(); + return; + } + if (!usbManager.hasPermission(device)) { + GeneralVariables.fileLog( + "playViaUsbAudio: ABORT no USB permission for output device " + + "(re-pick (USB direct) in Settings to re-grant)"); afterPlayAudio(); return; } UsbAudioDevice usbDev = new UsbAudioDevice(); if (!usbDev.open(context, device)) { - Log.e(TAG, "Failed to open USB audio output device"); + GeneralVariables.fileLog( + "playViaUsbAudio: ABORT UsbAudioDevice.open() failed " + + "(descriptor parse or claimInterface failed)"); afterPlayAudio(); return; } if (!usbDev.hasOutput()) { - Log.e(TAG, "USB audio device has no output endpoint"); + GeneralVariables.fileLog( + "playViaUsbAudio: ABORT device has no output endpoint"); usbDev.close(); afterPlayAudio(); return; } if (!usbDev.activateOutput(48000)) { - Log.e(TAG, "Failed to activate USB audio output"); + GeneralVariables.fileLog( + "playViaUsbAudio: ABORT activateOutput(48000) failed " + + "(alt-setting select or rate setup failed)"); usbDev.close(); afterPlayAudio(); return; } + GeneralVariables.fileLog( + "playViaUsbAudio: device opened, output activated at 48000 Hz"); // Skip leading samples if we started transmitting late, so audio still ends on the cycle boundary. int skipSamples = (lateStartSkipMs * GeneralVariables.audioSampleRate) / 1000; @@ -567,10 +599,12 @@ private void playViaUsbAudio(float[] buffer) { volumeAdjusted[i] = buffer[skipSamples + i] * GeneralVariables.volumePercent; } + GeneralVariables.fileLog(String.format( + "playViaUsbAudio: calling writeAudio playLength=%d rate=%d", + playLength, GeneralVariables.audioSampleRate)); boolean success = usbDev.writeAudio(volumeAdjusted, GeneralVariables.audioSampleRate); - if (!success) { - Log.e(TAG, "USB audio write failed"); - } + GeneralVariables.fileLog( + "playViaUsbAudio: writeAudio returned " + (success ? "OK" : "FAILED")); usbDev.close(); afterPlayAudio(); diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/settings/SettingsScreen.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/settings/SettingsScreen.kt index e61e269f..078c98ee 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/settings/SettingsScreen.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/settings/SettingsScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -418,22 +420,23 @@ fun SettingsScreen( } // -- TX Volume Editor -- + // Slider-based dialog with live update: dragging the thumb updates the + // in-memory volumePercent immediately so the next TX uses the new level, + // and we persist to the config DB on dismiss. Tapping outside the dialog + // or "Done" both commit. There's no "Cancel" because muscle-memory adjust + // is the whole point — if you dragged it, you meant it. if (showTxVolume) { - NumberInputDialog( - title = "TX Volume", - suffix = "%", + TxVolumeSliderDialog( initialValue = txVolume, - min = 0, - max = 100, - onDismiss = { showTxVolume = false }, - onSave = { value -> + onChange = { value -> + txVolume = value + GeneralVariables.volumePercent = value / 100f + GeneralVariables.mutableVolumePercent.postValue(value / 100f) + }, + onDismiss = { showTxVolume = false - val clamped = value.coerceIn(0, 100) - txVolume = clamped - GeneralVariables.volumePercent = clamped / 100f - GeneralVariables.mutableVolumePercent.postValue(clamped / 100f) - mainViewModel.databaseOpr.writeConfig("volumeValue", clamped.toString(), null) - mainViewModel.baseRig?.connector?.setRFVolume(clamped) + mainViewModel.databaseOpr.writeConfig("volumeValue", txVolume.toString(), null) + mainViewModel.baseRig?.connector?.setRFVolume(txVolume) }, ) } @@ -866,6 +869,14 @@ fun SettingsScreen( showAudioOutputPicker = true }, ) + SectionDivider() + SettingsRow( + label = "TX Volume", + description = "Transmit audio level (hardware buttons ±5%)", + value = "$txVolume%", + showChevron = true, + onClick = { showTxVolume = true }, + ) } } } @@ -905,14 +916,6 @@ fun SettingsScreen( showChevron = true, onClick = { showStopAfter = true }, ) - SectionDivider() - SettingsRow( - label = "TX Volume", - description = "Transmit audio level (hardware buttons ±5%)", - value = "$txVolume%", - showChevron = true, - onClick = { showTxVolume = true }, - ) } } } @@ -1751,7 +1754,81 @@ private fun SerialPortPickerDialog( } /** - * Simple informational dialog with a dismiss button. + * Slider-based TX volume editor with live update. Dragging commits the new + * level immediately (no Save/Cancel friction — the next TX uses what you + * dialed); we persist to the config DB only on dismiss. + */ +@Composable +private fun TxVolumeSliderDialog( + initialValue: Int, + onChange: (Int) -> Unit, + onDismiss: () -> Unit, +) { + var current by remember { mutableIntStateOf(initialValue) } + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(BgSurface2) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "TX Volume", + color = TextPrimary, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + ) + + // Big live readout so you can dial it in at a glance — useful in + // the car where the slider thumb is small relative to the radio's + // ALC meter you're watching at the same time. + Text( + text = "$current%", + color = Accent, + fontWeight = FontWeight.SemiBold, + fontSize = 48.sp, + ) + + Slider( + value = current.toFloat(), + onValueChange = { v -> + val clamped = v.toInt().coerceIn(0, 100) + if (clamped != current) { + current = clamped + onChange(clamped) + } + }, + valueRange = 0f..100f, + colors = SliderDefaults.colors( + thumbColor = Accent, + activeTrackColor = Accent, + ), + ) + + Text( + text = "Adjust live — your next TX uses this level. " + + "Watch your radio's ALC and dial down until it's just kissing the line.", + color = TextMuted, + fontSize = 12.sp, + lineHeight = 16.sp, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text("Done", color = Accent, fontWeight = FontWeight.SemiBold) + } + } + } + } +} + +/** + * Generic informational dialog: title + body text + dismiss. */ @Composable private fun InfoDialog( @@ -1842,6 +1919,21 @@ private fun AboutDialog( }, ) + Text( + text = "Website", + color = TextPrimary, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + ) + Text( + text = "ft8af.app", + color = Accent, + fontSize = 14.sp, + modifier = Modifier + .fillMaxWidth() + .clickable { uriHandler.openUri("https://ft8af.app") }, + ) + Text( text = "Built by", color = TextPrimary, diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/splash/SplashScreen.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/splash/SplashScreen.kt index 58e20f47..6a4d29cd 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/splash/SplashScreen.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/splash/SplashScreen.kt @@ -158,6 +158,16 @@ fun FT8USplashScreen( letterSpacing = 0.08.em, ) + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "ft8af.app", + color = Color(0xFF8A96B1).copy(alpha = 0.45f), + fontSize = 10.sp, + fontFamily = GeistMonoFamily, + letterSpacing = 0.08.em, + ) + Spacer(modifier = Modifier.height(96.dp)) } }