Harden beacon sessions and mark automatic messages

This commit is contained in:
Marc Froehlich committed 2026-11-14 19:18:25 +01:00
1 parent 19bcdb428d
commit f13fe54fde
63 files changed
+1905 -252

No files matched your search

@@ -473,7 +473,17 @@ profiles.changeAfterSwitch=Die Änderung wirkt erst nach einem erneuten Wechsel
profiles.alreadyActive=Dieses Profil ist bereits aktiv.
profiles.selectFirst=Zuerst ein Profil in der Tabelle wählen.
beacon.invalidMessage=Der Bakentext ist ungültig: {0}
beacon.intervalTooShort=Einen ganzzahligen Bakenabstand von mindestens {0} Minute eingeben.
beacon.intervalTooShort=Einen ganzzahligen Bakenabstand von mindestens {0} Minuten eingeben.
beacon.status.main=Hauptkategorie: {0}
beacon.status.second=Zweite Kategorie: {0}
beacon.status.enabled=aktiv
beacon.status.failed=Versand fehlgeschlagen
beacon.status.unavailable=nicht verfügbar
beacon.reason.session=in dieser Sitzung noch nicht aktiviert
beacon.reason.user=durch den Benutzer ausgeschaltet
beacon.reason.primaryQrg=Haupt-QRG fehlt oder ist ungültig
beacon.reason.secondaryQrg=zweite QRG fehlt oder ist ungültig
beacon.reason.automaticQrg=automatische QRG-Änderung um mehr als 5 kHz
notification.invalidPort=„{0}" ist kein gültiger TCP-Port. Einen Wert von 1 bis 65535 eingeben.
notification.invalidSpotter=„{0}" ist kein gültiges Spotter-Rufzeichen.
notification.invalidCallsign=Bitte ein gültiges Rufzeichen eingeben.
@@ -482,7 +482,17 @@ profiles.changeAfterSwitch=The change takes effect after switching to this profi
profiles.alreadyActive=This profile is already active.
profiles.selectFirst=Select a profile in the table first.
beacon.invalidMessage=The beacon message is invalid: {0}
beacon.intervalTooShort=Enter a whole beacon interval of at least {0} minute.
beacon.intervalTooShort=Enter a whole beacon interval of at least {0} minutes.
beacon.status.main=Main category: {0}
beacon.status.second=Second category: {0}
beacon.status.enabled=active
beacon.status.failed=send failed
beacon.status.unavailable=unavailable
beacon.reason.session=not activated in this session
beacon.reason.user=switched off by the operator
beacon.reason.primaryQrg=primary QRG missing or invalid
beacon.reason.secondaryQrg=second QRG missing or invalid
beacon.reason.automaticQrg=automatic QRG change greater than 5 kHz
notification.invalidPort="{0}" is not a valid TCP port. Enter a value from 1 to 65535.
notification.invalidSpotter="{0}" is not a valid spotter callsign.
notification.invalidCallsign=Please enter a valid callsign.
@@ -1,5 +1,6 @@
package kst4contest.view;
import kst4contest.controller.RadioSlot;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
@@ -97,4 +98,9 @@ public final class ComposeChatInputActions implements ChatInputActions {
String value = application.chatcontroller.getChatPreferences().getMYQRGSecondCat().get();
return value == null ? "" : value;
}
@Override
public void applyManualQrg(final RadioSlot slot, final String value) {
application.chatcontroller.applyManualQrg(slot, value);
}
}
@@ -0,0 +1,58 @@
package kst4contest.view.compose
import kst4contest.model.BeaconStatusSnapshot
import kst4contest.model.BeaconStatusSnapshot.AggregateState
import kst4contest.model.BeaconStatusSnapshot.CategoryStatus
import kst4contest.model.BeaconStatusSnapshot.InactiveReason
import kst4contest.view.i18n.CurrentStrings
import kst4contest.view.i18n.Strings
enum class ThreadStatusVisualStyle {
DEFAULT,
BEACON_OFF,
BEACON_ACTIVE,
BEACON_FAILED,
}
data class BeaconStatusPresentation(
val label: String,
val tooltip: String,
val style: ThreadStatusVisualStyle,
) {
companion object {
fun from(
snapshot: BeaconStatusSnapshot,
strings: Strings = CurrentStrings.get(),
): BeaconStatusPresentation {
val label = when (snapshot.aggregateState) {
AggregateState.OFF -> "off"
AggregateState.PARTIAL -> "1/2"
AggregateState.ON -> "on"
AggregateState.FAILED -> "FAILED"
}
val style = when (snapshot.aggregateState) {
AggregateState.OFF -> ThreadStatusVisualStyle.BEACON_OFF
AggregateState.PARTIAL, AggregateState.ON -> ThreadStatusVisualStyle.BEACON_ACTIVE
AggregateState.FAILED -> ThreadStatusVisualStyle.BEACON_FAILED
}
val tooltip = listOf(
strings.beaconStatusMain(describe(snapshot.main, strings)),
strings.beaconStatusSecond(describe(snapshot.second, strings)),
).joinToString("\n")
return BeaconStatusPresentation(label, tooltip, style)
}
private fun describe(category: CategoryStatus, strings: Strings): String {
if (!category.isAvailable()) return strings.beaconStatusUnavailable
if (category.isFailed()) return strings.beaconStatusFailed
if (category.isEnabled()) return strings.beaconStatusEnabled
return when (category.inactiveReason) {
InactiveReason.NOT_ACTIVATED_THIS_SESSION -> strings.beaconReasonSession
InactiveReason.USER_DISABLED -> strings.beaconReasonUser
InactiveReason.PRIMARY_QRG_UNAVAILABLE -> strings.beaconReasonPrimaryQrg
InactiveReason.SECONDARY_QRG_UNAVAILABLE -> strings.beaconReasonSecondaryQrg
InactiveReason.AUTOMATIC_QRG_CHANGE -> strings.beaconReasonAutomaticQrg
}
}
}
}
@@ -163,12 +163,12 @@ fun ChatInputRow(
// profile on every keystroke.
InlineCommittedField(
stored = ownQrgMain.value.orEmpty(),
onCommit = { prefs.getMYQRGFirstCat().set(it) },
onCommit = { state.commitOwnQrg(mainCategory = true, it) },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatOwnQrgTooltip,
)
InlineCommittedField(
stored = ownQrgSecond.value.orEmpty(),
onCommit = { prefs.getMYQRGSecondCat().set(it) },
onCommit = { state.commitOwnQrg(mainCategory = false, it) },
tooltip = if (so2rEnabled) {
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltipSo2r
} else {
@@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import kst4contest.controller.RadioSlot
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
@@ -46,6 +47,8 @@ interface ChatInputActions {
fun ownQrgMain(): String
fun ownQrgSecond(): String
fun applyManualQrg(slot: RadioSlot, value: String) {}
}
/**
@@ -150,6 +153,10 @@ class ChatInputState(private val actions: ChatInputActions) {
appendAndFocus("$qrg ")
}
fun commitOwnQrg(mainCategory: Boolean, value: String) {
actions.applyManualQrg(if (mainCategory) RadioSlot.FIRST else RadioSlot.SECOND, value)
}
/**
* Replaces the field with "/cq CALL <snippet>", resolved.
*
@@ -194,6 +194,10 @@ fun buildSettingsTabs(
setSecondBeaconEnabled = controller::setSecondBeaconEnabled,
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
secondCategoryName = categoryName(controller.chatCategorySecondChat, kst4contest.view.i18n.CurrentStrings.get().settingsCategorySecondFallback),
onBeaconTemplatesApplied = {
controller.onMainBeaconTemplateApplied()
controller.onSecondBeaconTemplateApplied()
},
)
val logSynchState = LogSynchTabState(
@@ -26,6 +26,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -202,8 +203,20 @@ private fun ThreadStatusButtonView(button: ThreadStatusButton) {
}
val scheme = MaterialTheme.colorScheme
val background = if (button.highlighted) scheme.primary else scheme.surfaceVariant
val foreground = if (button.highlighted) scheme.onPrimary else scheme.onSurfaceVariant
val colors = if (button.visualStyle == ThreadStatusVisualStyle.DEFAULT) {
ThreadButtonColors(
background = if (button.highlighted) scheme.primary else scheme.surfaceVariant,
border = null,
text = if (button.highlighted) scheme.onPrimary else scheme.onSurfaceVariant,
)
} else {
beaconThreadButtonColors(
style = button.visualStyle,
highlighted = button.highlighted,
darkTheme = scheme.surface.luminance() < 0.5f,
neutralBackground = scheme.surfaceVariant,
)
}
TooltipArea(tooltip = { TooltipCard(button.tooltip) }) {
val actionModifier = if (button.hasAction) {
@@ -211,17 +224,20 @@ private fun ThreadStatusButtonView(button: ThreadStatusButton) {
} else {
Modifier
}
Box(
modifier = Modifier
var boxModifier = Modifier
.defaultMinSize(minHeight = 22.dp)
.background(background, NOTICE_SHAPE)
.background(colors.background, NOTICE_SHAPE)
.then(actionModifier)
.padding(NOTICE_PADDING),
colors.border?.let { border ->
boxModifier = boxModifier.border(1.dp, border, NOTICE_SHAPE)
}
Box(
modifier = boxModifier.padding(NOTICE_PADDING),
contentAlignment = Alignment.Center,
) {
Text(
text = button.label,
color = foreground,
color = colors.text,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
)
@@ -229,6 +245,33 @@ private fun ThreadStatusButtonView(button: ThreadStatusButton) {
}
}
internal data class ThreadButtonColors(
val background: Color,
val border: Color?,
val text: Color,
)
internal fun beaconThreadButtonColors(
style: ThreadStatusVisualStyle,
highlighted: Boolean,
darkTheme: Boolean,
neutralBackground: Color,
): ThreadButtonColors = when (style) {
ThreadStatusVisualStyle.BEACON_ACTIVE -> if (highlighted) {
ThreadButtonColors(Color(0xFFFFB300), Color(0xFFFFE082), Color(0xFF1B1B1B))
} else {
ThreadButtonColors(Color(0xFF238636), Color(0xFF56D364), Color.White)
}
ThreadStatusVisualStyle.BEACON_FAILED ->
ThreadButtonColors(Color(0xFFD50000), Color(0xFFFF6B6B), Color.White)
ThreadStatusVisualStyle.BEACON_OFF -> ThreadButtonColors(
neutralBackground,
null,
if (darkTheme) Color(0xFFFF6B6B) else Color(0xFFD50000),
)
ThreadStatusVisualStyle.DEFAULT -> error("Default worker colours come from the theme")
}
/** The tooltips in this bar carry the long text the badges had to cut. */
@Composable
internal fun TooltipCard(text: String) {
@@ -187,9 +187,11 @@ class ThreadStatusButtons {
val button = bySource.getOrPut(sourceName) {
ThreadStatusButton(sourceName, actions[sourceName]).also { ordered.add(it) }
}
val beacon = effectiveMessage.beaconStatusSnapshot?.let(BeaconStatusPresentation::from)
button.report(
label = "$sourceName: ${effectiveMessage.runningInformationTextDescription}",
tooltip = effectiveMessage.runningInformation.orEmpty(),
label = "$sourceName: ${beacon?.label ?: effectiveMessage.runningInformationTextDescription}",
tooltip = beacon?.tooltip ?: effectiveMessage.runningInformation.orEmpty(),
style = beacon?.style ?: ThreadStatusVisualStyle.DEFAULT,
)
}
@@ -226,6 +228,9 @@ class ThreadStatusButton(
var highlighted: Boolean by mutableStateOf(false)
private set
var visualStyle: ThreadStatusVisualStyle by mutableStateOf(ThreadStatusVisualStyle.DEFAULT)
private set
var revision: Int by mutableStateOf(0)
private set
@@ -235,10 +240,16 @@ class ThreadStatusButton(
val hasAction: Boolean
get() = action != null
internal fun report(label: String, tooltip: String) {
internal fun report(
label: String,
tooltip: String,
style: ThreadStatusVisualStyle = ThreadStatusVisualStyle.DEFAULT,
) {
this.label = label
this.tooltip = tooltip
highlighted = true
visualStyle = style
highlighted = style == ThreadStatusVisualStyle.DEFAULT ||
style == ThreadStatusVisualStyle.BEACON_ACTIVE
revision++
}
@@ -30,6 +30,7 @@ class BeaconTabState(
val secondCategoryName: String,
private val setMainBeaconEnabled: (Boolean) -> Unit = prefs::setBcn_beaconsEnabledMainCat,
private val setSecondBeaconEnabled: (Boolean) -> Unit = prefs::setBcn_beaconsEnabledSecondCat,
private val onBeaconTemplatesApplied: () -> Unit = {},
) {
val mainBeaconEnabled = prefs.bcnBeaconsEnabledMainCatProperty()
@@ -45,10 +46,14 @@ class BeaconTabState(
private var textMainCat: String by draft.setting(
{ prefs.getBcn_beaconTextMainCat() },
effectKey = BEACON_TEMPLATE_EFFECT,
effect = onBeaconTemplatesApplied,
) { prefs.setBcn_beaconTextMainCat(it) }
private var textSecondCat: String by draft.setting(
{ prefs.getBcn_beaconTextSecondCat() },
effectKey = BEACON_TEMPLATE_EFFECT,
effect = onBeaconTemplatesApplied,
) { prefs.setBcn_beaconTextSecondCat(it) }
/*
@@ -123,4 +128,8 @@ class BeaconTabState(
interval = minutes
return null
}
private companion object {
const val BEACON_TEMPLATE_EFFECT = "beaconTemplates"
}
}
@@ -0,0 +1,58 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class BeaconStatusColorsTest {
private val neutral = Color(0xFF123456)
@Test
fun `active and activity colours are the approved traffic colours`() {
assertEquals(
ThreadButtonColors(Color(0xFF238636), Color(0xFF56D364), Color.White),
beaconThreadButtonColors(ThreadStatusVisualStyle.BEACON_ACTIVE, false, false, neutral),
)
assertEquals(
ThreadButtonColors(Color(0xFFFFB300), Color(0xFFFFE082), Color(0xFF1B1B1B)),
beaconThreadButtonColors(ThreadStatusVisualStyle.BEACON_ACTIVE, true, false, neutral),
)
}
@Test
fun `off uses neutral background and theme specific red text`() {
assertEquals(Color(0xFFD50000),
beaconThreadButtonColors(ThreadStatusVisualStyle.BEACON_OFF, false, false, neutral).text)
assertEquals(Color(0xFFFF6B6B),
beaconThreadButtonColors(ThreadStatusVisualStyle.BEACON_OFF, false, true, neutral).text)
assertEquals(neutral,
beaconThreadButtonColors(ThreadStatusVisualStyle.BEACON_OFF, false, true, neutral).background)
}
@Test
fun `failed stays red even when a highlight is requested`() {
val colors = beaconThreadButtonColors(
ThreadStatusVisualStyle.BEACON_FAILED,
true,
false,
neutral,
)
assertEquals(ThreadButtonColors(Color(0xFFD50000), Color(0xFFFF6B6B), Color.White), colors)
}
@Test
fun `failed suppresses the flash while active status allows it`() {
val button = ThreadStatusButton("MyBeacon")
button.report("MyBeacon: on", "active", ThreadStatusVisualStyle.BEACON_ACTIVE)
assertTrue(button.highlighted)
button.report("MyBeacon: FAILED", "failed", ThreadStatusVisualStyle.BEACON_FAILED)
assertFalse(button.highlighted)
assertEquals(
ThreadButtonColors(Color(0xFFD50000), Color(0xFFFF6B6B), Color.White),
beaconThreadButtonColors(ThreadStatusVisualStyle.BEACON_FAILED, true, false, neutral),
)
}
}
@@ -0,0 +1,59 @@
package kst4contest.view.compose
import kst4contest.model.BeaconStatusSnapshot
import kst4contest.model.BeaconStatusSnapshot.AggregateState
import kst4contest.model.BeaconStatusSnapshot.CategoryStatus
import kst4contest.model.BeaconStatusSnapshot.InactiveReason
import kst4contest.view.i18n.LanguageStore
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.Locale
class BeaconStatusPresentationTest {
@Test
fun `partial status keeps the inactive category reason in English`() {
val presentation = BeaconStatusPresentation.from(snapshot(AggregateState.PARTIAL))
assertEquals("1/2", presentation.label)
assertTrue(presentation.tooltip.contains("Main category: active"))
assertTrue(presentation.tooltip.contains("primary QRG missing or invalid"))
}
@Test
fun `the same category reasons are available in German`() {
val language = LanguageStore({ "de" }, {}, Locale.UK)
try {
val presentation = BeaconStatusPresentation.from(
snapshot(AggregateState.PARTIAL),
language.strings,
)
assertTrue(presentation.tooltip.contains("Hauptkategorie: aktiv"))
assertTrue(presentation.tooltip.contains("Haupt-QRG fehlt oder ist ungültig"))
assertFalse(presentation.tooltip.contains("144."))
} finally {
language.use("en")
}
}
@Test
fun `failed style overrides an active category`() {
val main = CategoryStatus(true, true, true, InactiveReason.USER_DISABLED)
val second = CategoryStatus(false, false, false, InactiveReason.NOT_ACTIVATED_THIS_SESSION)
val presentation = BeaconStatusPresentation.from(
BeaconStatusSnapshot(AggregateState.FAILED, main, second),
)
assertEquals("FAILED", presentation.label)
assertEquals(ThreadStatusVisualStyle.BEACON_FAILED, presentation.style)
assertTrue(presentation.tooltip.contains("send failed"))
}
private fun snapshot(state: AggregateState): BeaconStatusSnapshot = BeaconStatusSnapshot(
state,
CategoryStatus(true, true, false, InactiveReason.USER_DISABLED),
CategoryStatus(true, false, false, InactiveReason.PRIMARY_QRG_UNAVAILABLE),
)
}
@@ -5,6 +5,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.controller.RadioSlot
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
@@ -233,6 +234,20 @@ class ChatInputStateTest {
assertEquals("432.200 ", state.value.text)
}
@Test
fun `manual QRG commits keep their radio source explicit`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.commitOwnQrg(mainCategory = true, "144.300")
state.commitOwnQrg(mainCategory = false, "1296.100")
assertEquals(
listOf(RadioSlot.FIRST to "144.300", RadioSlot.SECOND to "1296.100"),
actions.manualQrgs,
)
}
// ---- the snippets ------------------------------------------------------
/**
@@ -407,6 +422,7 @@ class ChatInputStateTest {
val resolvedTemplates = mutableListOf<String>()
val recordedCqs = mutableListOf<String>()
val recomputeReasons = mutableListOf<String>()
val manualQrgs = mutableListOf<Pair<RadioSlot, String>>()
override fun panelSelection(): ChatMember? = panelSelection
override fun tableSelection(): ChatMember? = tableSelection
@@ -431,5 +447,6 @@ class ChatInputStateTest {
override fun queue(message: ChatMessage) { queued += message }
override fun ownQrgMain(): String = ownQrgMain
override fun ownQrgSecond(): String = ownQrgSecond
override fun applyManualQrg(slot: RadioSlot, value: String) { manualQrgs += slot to value }
}
}
@@ -173,13 +173,24 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
val state = s.state()
assertNotNull(state.commitBeaconInterval("0"))
assertNotNull(state.commitBeaconInterval("4"))
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
assertEquals(0, s.timerRestarts,
"restarting the timer per rejected keystroke would disturb the cooldown")
}
@Test
fun `exactly five minutes is accepted`() {
val s = Session()
val state = s.state()
assertNull(state.commitBeaconInterval("5"))
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesSecondCat())
}
@Test
fun `an interval that is not a whole number is refused`() {
val s = Session()
@@ -73,7 +73,7 @@ class RemainingTextsFollowTheLanguageTest {
assertEquals("Wertung", score.title(), "the column title stayed in the old language")
assertEquals(
"Einen ganzzahligen Bakenabstand von mindestens 1 Minute eingeben.",
"Einen ganzzahligen Bakenabstand von mindestens 5 Minuten eingeben.",
beacon.commitBeaconInterval("0"),
"the refusal from the tab state stayed in the old language",
)