diff --git a/.gitignore b/.gitignore
index 4c9f50a6..e9632171 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,12 @@ debug.out
SimpleLogFile.txt
udpReaderBackup.txt
+# Runtime/debug logs
+Messagehistory.raw
+Messagehistory-*.raw
+kst4contest-errors.log
+kst4contest-errors.log.lck
+
#tempfiles
.idea/
out/
diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md
index 63fe1cce..520a7335 100644
--- a/docs/PROJECT_CONTEXT.md
+++ b/docs/PROJECT_CONTEXT.md
@@ -1,6 +1,6 @@
# KST4Contest Project Context
-Last reviewed: 2026-09-14
+Last reviewed: 2026-10-03
This file is the durable technical project context for KST4Contest. It is not a user manual and not a replacement for the changelog. Current code, tests and authoritative external specifications remain the source of truth when this document is stale or ambiguous.
@@ -52,7 +52,7 @@ JavaFX ObservableList / UI state
## Configuration and Layout Persistence
-- The current `preferences.xml` configuration version is 7. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns.
+- The current `preferences.xml` configuration version is 8. Version 8 adds the default-enabled `messageHandling_debugModeToFileEnabled` setting. Missing entries in older files retain the enabled default. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns.
- `GUIstationMapClusteringEnabled` is a layout preference below `guiOptions`. It defaults to `true`, is selectively autosaved and controls only screen-based clustering of nearby map markers. Missing or malformed values retain the enabled default for backward compatibility.
- Stored widths take precedence. Without a usable entry, a managed column is sized once when meaningful table data first becomes available. Message and similar free-text columns use a flexible initial width instead of following the longest value.
- Main-window and separate-monitor DXCluster/QSO tables use distinct layout IDs even though they share the underlying message stores.
@@ -132,6 +132,17 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
- If no inbound frame arrives by about 210 seconds, the existing reconnect flow remains responsible for replacing the session.
- Liveness diagnostics contain the session id, opcode and timing only. They must not include credentials, complete server frames or normal chat messages.
+### ON4KST replay history and diagnostic logging
+
+- `MessageHistoryRecorder` is the single non-JavaFX owner of raw ON4KST session-history file I/O. Reader and writer threads only report complete frames to it.
+- The current program session is stored at `~/.praktiKST/Messagehistory.raw` as one `UTCRX|TXRAW_FRAME` event per line. Timestamps use UTC with milliseconds; TCP CR/LF terminators are not part of the raw frame.
+- RX is recorded immediately after `BufferedReader.readLine()`, before liveness handling or internal filtering. TX is recorded only after the final frame has been written and flushed successfully.
+- A non-empty current file is archived once when recording is first initialised for a new program session. Reconnects do not rotate it. Disabling closes the writer; re-enabling in the same program session appends to the same current file.
+- `LOGINC`/`LOGIN` password fields are replaced by `` through protocol-aware redaction. Other frames are not reconstructed or normalised for history output.
+- History I/O failures are warning-level diagnostics and must never terminate reader, writer or message-processing threads or deliberately disconnect ON4KST.
+- The default-enabled Messagehandling setting also controls additional application `FINE`/`INFO` records in `kst4contest-errors.log`. `WARNING`/`SEVERE` records and exceptions remain file-logged regardless of that setting. File-log timestamps use UTC with milliseconds.
+- The raw history is intended for future replay/simulation tooling and can contain personal chat and station data even though credentials are redacted.
+
## User Workflow / UI Invariants
- Contest operating speed and low-friction interaction are primary goals.
diff --git a/github_docs/de-Home.md b/github_docs/de-Home.md
index 113138b0..90542b51 100644
--- a/github_docs/de-Home.md
+++ b/github_docs/de-Home.md
@@ -107,6 +107,8 @@ Die Fehler-Logdatei wird hier gespeichert:
Vor dem Hochladen sollte die Datei kurz geprüft werden. Ein Fehlerprotokoll enthält überwiegend technische Informationen, kann abhängig vom Fehler aber beispielsweise lokale Dateipfade oder weitere Kontextdaten enthalten.
+Ist unter **Settings → Messagehandling → Enable debug mode to file** der Debug-Modus aktiviert, enthält das gleiche Verzeichnis zusätzlich `Messagehistory.raw` und gegebenenfalls archivierte `Messagehistory-*.raw`. Diese Dateien zeichnen den vollständigen ON4KST-Sessionverkehr mit RX und TX auf. Loginpasswörter werden redigiert, Chat- und Stationsdaten bleiben jedoch enthalten. Eine solche Datei deshalb nur nach bewusster Prüfung weitergeben.
+
In Datei- und Verzeichnisnamen wird teilweise noch der technische Name `praktiKST` verwendet. Gemeint ist dasselbe Programm.
---
diff --git a/github_docs/de-Konfiguration.md b/github_docs/de-Konfiguration.md
index 89e454a8..1bbaa949 100644
--- a/github_docs/de-Konfiguration.md
+++ b/github_docs/de-Konfiguration.md
@@ -711,6 +711,29 @@ Ist für die betreffende Kategorie keine QRG vorhanden, sendet KST4Contest keine
Sind die allgemeine und die QRG-bezogene Antwort gleichzeitig aktiviert, hat die QRG-Antwort Vorrang. Eine erkannte QRG-Anfrage erzeugt daher nicht zusätzlich den allgemeinen Antworttext. Fehlt die benötigte QRG, fällt KST4Contest auch nicht auf die allgemeine Antwort zurück.
+### Debug- und Contest-History
+
+**Enable debug mode to file** ist standardmäßig aktiviert und wirkt ohne Neustart. Der Schalter steuert zwei getrennte Dateien im Verzeichnis `.praktiKST` des Benutzerprofils:
+
+- `kst4contest-errors.log` enthält bei aktiviertem Debug-Modus zusätzliche `INFO`- und `FINE`-Diagnosen. Warnungen, Fehler und Exceptions werden unabhängig vom Schalter immer geschrieben.
+- `Messagehistory.raw` zeichnet den vollständigen ON4KST-Verkehr der aktuellen Programmsitzung auf. Dazu gehören RX und TX, also neben Chat- und Privatnachrichten auch Benutzer-, Status-, Control- und Keepalive-Frames.
+
+Unter Windows liegt die History beispielsweise unter `C:\Users\\.praktiKST\Messagehistory.raw`, unter Linux und macOS unter `~/.praktiKST/Messagehistory.raw`.
+
+Jede Zeile enthält genau ein Ereignis:
+
+```text
+UTCRX|TXRAW_FRAME
+```
+
+Der UTC-Zeitstempel enthält Millisekunden. Das dritte Feld bleibt bis auf die Entfernung des TCP-Zeilenabschlusses unverändert. Loginframes bleiben für eine spätere Analyse erkennbar, das Passwort wird jedoch durch `` ersetzt.
+
+Beim ersten Aktivieren in einer Programmsitzung wird eine vorhandene, nichtleere `Messagehistory.raw` als `Messagehistory-YYYYMMDD-HHMMSSZ.raw` archiviert. Reconnects und erneutes Aktivieren innerhalb derselben Programmsitzung erzeugen kein weiteres Archiv. Beim Abschalten endet die Aufzeichnung sofort; erneutes Aktivieren hängt an die aktuelle Datei an.
+
+Die History ist als maschinenlesbare Grundlage für spätere Replay- und Simulationswerkzeuge gedacht. Sie enthält den vollständigen Contest-/Session-Verkehr und damit in der Regel personenbezogene Chat- und Stationsdaten. Vor einer Weitergabe muss die Datei entsprechend geprüft und behandelt werden.
+
+Das vorhandene Bild `client_settings_window_messagehandling.png` zeigt die neue Checkbox noch nicht und sollte bei der nächsten Screenshot-Aktualisierung ersetzt werden.
+
### Schutz vor wiederholten Antworten
Jede automatisch erzeugte Nachricht trägt das feste Präfix:
diff --git a/github_docs/en-Configuration.md b/github_docs/en-Configuration.md
index a1a9c373..d763ad1b 100644
--- a/github_docs/en-Configuration.md
+++ b/github_docs/en-Configuration.md
@@ -711,6 +711,29 @@ If no QRG is available for the incoming category, KST4Contest does not send an i
When both automatic-reply functions are enabled, the QRG reply takes precedence. A recognised QRG request does not additionally produce the general reply. If the required QRG is missing, KST4Contest does not fall back to the general answer.
+### Debug and contest history
+
+**Enable debug mode to file** is enabled by default and takes effect without restarting the application. The setting controls two separate files in the `.praktiKST` directory of the user profile:
+
+- `kst4contest-errors.log` includes additional `INFO` and `FINE` diagnostics while debug mode is enabled. Warnings, errors and exceptions are written regardless of this setting.
+- `Messagehistory.raw` records the complete ON4KST traffic of the current program session. It contains RX and TX traffic, including chat, private-message, user, status, control and keepalive frames.
+
+On Windows, the history is stored at `C:\Users\\.praktiKST\Messagehistory.raw`; on Linux and macOS it is stored at `~/.praktiKST/Messagehistory.raw`.
+
+Each line contains exactly one event:
+
+```text
+UTCRX|TXRAW_FRAME
+```
+
+The UTC timestamp includes milliseconds. The third field is preserved unchanged except that the TCP line ending is not stored. Login frames remain recognisable for later analysis, but the password is replaced with ``.
+
+When recording is initialised for a new program session, an existing non-empty `Messagehistory.raw` is archived as `Messagehistory-YYYYMMDD-HHMMSSZ.raw`. Reconnects and repeated enabling during the same program session do not create another archive. Disabling the setting stops recording immediately; enabling it again appends to the current file.
+
+The history is intended as a machine-readable basis for future replay and simulation tools. It contains the complete contest/session traffic and will normally include personal chat and station data. Inspect and handle the file accordingly before sharing it.
+
+The existing `client_settings_window_messagehandling.png` screenshot does not yet show the new checkbox and should be replaced during the next screenshot update.
+
### Protection against repeated replies
Every automatically generated reply contains the fixed prefix:
diff --git a/github_docs/en-Home.md b/github_docs/en-Home.md
index d835d570..7321dc6f 100644
--- a/github_docs/en-Home.md
+++ b/github_docs/en-Home.md
@@ -109,6 +109,8 @@ The error log is stored at:
Please inspect the file briefly before uploading it. An error log mainly contains technical information, but depending on the problem it may also include local file paths or other contextual data.
+When **Settings → Messagehandling → Enable debug mode to file** is enabled, the same directory also contains `Messagehistory.raw` and possibly archived `Messagehistory-*.raw` files. These files record the complete ON4KST session traffic in both directions. Login passwords are redacted, but chat and station data remains present. Share such a file only after reviewing it deliberately.
+
Some file and directory names still use the technical name `praktiKST`. They refer to the same program.
---
diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java
index dfe9fa17..c3e5e38b 100644
--- a/src/main/java/kst4contest/controller/ChatController.java
+++ b/src/main/java/kst4contest/controller/ChatController.java
@@ -25,6 +25,7 @@ import kst4contest.logic.BandOpportunityResolver;
import kst4contest.logic.PriorityCalculator;
import kst4contest.model.*;
import kst4contest.test.MockKstServer;
+import kst4contest.utils.ApplicationFileUtils;
import kst4contest.utils.PlayAudioUtils;
import kst4contest.locatorUtils.Location;
import kst4contest.view.Kst4ContestApplication;
@@ -114,6 +115,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
boolean connectedAndNOTLoggedIn;
boolean disconnected;
boolean disconnectionPerformedByUser = false;
+ private MessageHistoryRecorder messageHistoryRecorder =
+ MessageHistoryRecorder.disabled();
private final On4KstConnectionManager on4KstConnectionManager =
new On4KstConnectionManager(this);
@@ -2454,6 +2457,24 @@ private ObservableList
* reachability worker threads. Therefore the listener is always invoked on the
* JavaFX application thread.
*
+ * This overload requests the functional refresh without producing a periodic
+ * diagnostic reason.
+ */
+ public void fireUserListUpdate() {
+ if (statusListener == null) {
+ return;
+ }
+
+ if (Platform.isFxApplicationThread()) {
+ statusListener.onUserListUpdated();
+ } else {
+ Platform.runLater(statusListener::onUserListUpdated);
+ }
+ }
+
+ /**
+ * Notifies the UI that station-list derived values have changed.
+ *
* @param reason short debug reason for the UI log
*/
public void fireUserListUpdate(String reason) {
@@ -2870,6 +2891,13 @@ private ObservableList
chatPreferences = new ChatPreferences();
chatPreferences.readPreferencesFromXmlFile();
+ Path historyFile = Path.of(ApplicationFileUtils.getFilePath(
+ ApplicationConstants.APPLICATION_NAME,
+ MessageHistoryRecorder.CURRENT_FILE_NAME));
+ messageHistoryRecorder = new MessageHistoryRecorder(
+ historyFile.getParent(),
+ chatPreferences.isMessageHandling_debugModeToFileEnabled(),
+ java.time.Clock.systemUTC());
// this.statusListener = listener;
lstNotify_QSOSniffer_sniffedCallSignList =
chatPreferences
@@ -3108,6 +3136,15 @@ private ObservableList
return chatPreferences;
}
+ public MessageHistoryRecorder getMessageHistoryRecorder() {
+ return messageHistoryRecorder;
+ }
+
+ /** Applies the runtime debug preference to the raw session recorder. */
+ public void setMessageHistoryRecordingEnabled(boolean enabled) {
+ messageHistoryRecorder.setEnabled(enabled);
+ }
+
public void setChatPreferences(ChatPreferences chatPreferences) {
this.chatPreferences = chatPreferences;
}
diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/src/main/java/kst4contest/controller/MessageBusManagementThread.java
index 52be2056..887cffea 100644
--- a/src/main/java/kst4contest/controller/MessageBusManagementThread.java
+++ b/src/main/java/kst4contest/controller/MessageBusManagementThread.java
@@ -11,6 +11,8 @@ import java.util.Hashtable;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.ObservableList;
@@ -30,6 +32,8 @@ import java.util.function.LongPredicate;
*
*/
public class MessageBusManagementThread extends Thread {
+ private static final Logger LOGGER =
+ Logger.getLogger(MessageBusManagementThread.class.getName());
int index;
@@ -255,7 +259,7 @@ public class MessageBusManagementThread extends Thread {
stringAggregation += s + " ";
}
- System.out.println("[MSGBUSMGT:] Processed qrg info: " + stringAggregation);
+ LOGGER.log(Level.FINE, "Processed QRG info: {0}", stringAggregation);
// System.out.println("Processed QRG Entry [" + this.client.getChatMemberTable().size() + "]: Call: "
// + member.getCallSign() + ", QRA: " + member.getQra() + ", Name: " + member.getName());
@@ -442,7 +446,7 @@ public class MessageBusManagementThread extends Thread {
finalDetectedFrequency
);
- System.out.println(
+ LOGGER.log(Level.FINE,
"[SmartParser] Detected for "
+ sender.getCallSign()
+ ": "
@@ -578,11 +582,11 @@ public class MessageBusManagementThread extends Thread {
if (lookForThis == null) {
- System.out.println(
+ LOGGER.warning(
"[ChecklistForChatMemberIndexByCallsign] ERROR: null Value for Chatmember detected! Member cannot be in the list!");
return -1;
} else if (lookForThis.getCallSign() == null) {
- System.out.println(
+ LOGGER.warning(
"[ChecklistForChatMemberIndexByCallsign] ERROR: null Value in Callsign detected! Member cannot be in the list!");
return -1;
}
@@ -613,7 +617,11 @@ public class MessageBusManagementThread extends Thread {
if (list.get(i).getCallSign().equals(lookForThis.getCallSign())) {
//TODO: New since 1.26! Check against category!
- System.out.println("MSGBUSMGT, DEBUG: Checking Chatcategories of found list member " + list.get(i).getCallSign() + " / " + list.get(i).getChatCategory() + " against " + lookForThis.getCallSign() + " / " + lookForThis.getChatCategory());
+ LOGGER.fine("Checking chat categories for list member "
+ + list.get(i).getCallSign() + " / "
+ + list.get(i).getChatCategory() + " against "
+ + lookForThis.getCallSign() + " / "
+ + lookForThis.getChatCategory());
// System.out
// .println("MSGBUSHELBER: Found " + chatMember.getCallSign() + " at " + list.indexOf(chatMember));
@@ -623,12 +631,14 @@ public class MessageBusManagementThread extends Thread {
return list.indexOf(list.get(i));
} //new 1.26
else {
- System.out.println("MSGBUSMGT, DEBUG: Category does not match");
+ LOGGER.fine("Chat-member category does not match");
}
// System.out.println("--------------------------- chatcategory of list.get(i) = " + list.get(i).getChatCategory().getCategoryNumber());
- System.out.println("--------------------------- chatcategory of lookforthisChatMember = " + lookForThis.getChatCategory().getCategoryNumber() );
+ LOGGER.log(Level.FINE,
+ "Requested chat-member category is {0}",
+ lookForThis.getChatCategory().getCategoryNumber());
}
// return list.indexOf(list.get(i)); //if no category found, return entry //TODO: ERROR detected here! Should work now, needs some proof
// return -1; //if category dont match, return: member not found
@@ -1010,11 +1020,11 @@ public class MessageBusManagementThread extends Thread {
newMember.setCallSign(splittedMessageLine[2]);
- System.out.println("[MSGBUSMGT, Info:] User left Chat and will be removed from list ["
+ LOGGER.info("User left chat and will be removed from list ["
+ this.client.getActiveChatMemberCount() + "] :" + newMember.getCallSign());
if (!this.client.removeActiveChatMember(newMember)) {
- System.out.println("[MSGBUSMGT, Info:] User sent left chat but was not active: "
+ LOGGER.info("User sent left-chat frame but was not active: "
+ newMember.getCallSign() + " / " + newMember.getChatCategory());
}
@@ -1242,7 +1252,8 @@ public class MessageBusManagementThread extends Thread {
}
- System.out.println("message directed to me: " + newMessageArrived.getReceiver().getCallSign() + ".");
+ LOGGER.fine("Message directed to local callsign: "
+ + newMessageArrived.getReceiver().getCallSign());
} else if (newMessageArrived.getSender().getCallSign().toUpperCase()
.equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) {
@@ -1320,24 +1331,27 @@ public class MessageBusManagementThread extends Thread {
}
} catch (Exception exception) {
- System.out.println("[MSGBUSMGT, ERROR:] DXCluster messageserver error while processing spot for 0: " + newMessageArrived.getSender().getCallSign() + " // " + exception.getMessage());
+ LOGGER.log(Level.WARNING,
+ "DXCluster message-server error while processing spot for "
+ + newMessageArrived.getSender().getCallSign(),
+ exception);
// exception.printStackTrace();
}
}
- System.out.println(">>>>>>>>>> Anglewarning <<<<<<<<<< " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " +
+ LOGGER.fine("Angle warning " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " +
new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(newMessageArrived.getReceiver().getQra())) +
" / sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(client.getChatPreferences().getStn_loginLocatorMainCat())));
} else {
- System.out.println("-notinangle- " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " +
+ LOGGER.fine("Not in angle " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " +
new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(newMessageArrived.getReceiver().getQra())) +
" ; sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(client.getChatPreferences().getStn_loginLocatorMainCat())));
newMessageArrived.getSender().setInAngleAndRange(false);
}
} else {
newMessageArrived.getSender().setInAngleAndRange(false);
- System.out.println("[MSGBUSMGT, Info:] Skipping angle/range analysis for message with missing locator: "
+ LOGGER.info("Skipping angle/range analysis for message with missing locator: "
+ newMessageArrived.getSender().getCallSign() + " -> "
+ newMessageArrived.getReceiver().getCallSign());
}
@@ -1347,9 +1361,9 @@ public class MessageBusManagementThread extends Thread {
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
}
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
- System.out.println("MSGBS bgfx, <<>>: referenced user left the chat during messageprocessing or message got before user entered chat message: "
- + referenceDeletedByUserLeftChatDuringMessageprocessing.getMessage());
- referenceDeletedByUserLeftChatDuringMessageprocessing.printStackTrace();
+ LOGGER.log(Level.WARNING,
+ "Referenced user disappeared while processing a message",
+ referenceDeletedByUserLeftChatDuringMessageprocessing);
}
// sdtout to me message-List
@@ -1364,12 +1378,13 @@ public class MessageBusManagementThread extends Thread {
try {
- System.out.println("[MSGBUSMGT:] processed message: " + newMessageArrived.getChatCategory().getCategoryNumber()
+ LOGGER.fine("Processed message: " + newMessageArrived.getChatCategory().getCategoryNumber()
+ " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> "
+ newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText());
} catch (Exception exceptionOccured) {
- System.out.println("[MSGMgtBus: ERROR CHATCHED ON MAYBE NULL ISSUE]: " + exceptionOccured.getMessage());
- exceptionOccured.printStackTrace();
+ LOGGER.log(Level.WARNING,
+ "Could not format processed-message diagnostic",
+ exceptionOccured);
}
// TODO: Next: get frequency infos out of name?
@@ -1401,11 +1416,11 @@ public class MessageBusManagementThread extends Thread {
}
if (foundThisInChatMemberList != null) {
- System.out.println("[MSGBUSMGT:] Locator Change of [" + (splittedMessageLine[2] + "], old was: "
+ LOGGER.info("Locator change of [" + (splittedMessageLine[2] + "], old was: "
+ foundThisInChatMemberList.getQra() + " new is: " + splittedMessageLine[3]));
this.client.updateActiveChatMemberLocator(temp4, splittedMessageLine[3]);
} else {
- System.out.println("[MSGBUSMGT:] ERROR! Locator Change of ["
+ LOGGER.warning("Locator change of ["
+ (splittedMessageLine[2] + "] is not possible, user is not in the Table!"));
}
@@ -1537,7 +1552,7 @@ public class MessageBusManagementThread extends Thread {
stateChangeMember.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1]));
if (!this.client.updateActiveChatMemberState(stateChangeMember, stateChangeMember.getState())) {
- System.out.println("[MSGBUSMGT, Info:] State change for inactive user: "
+ LOGGER.info("State change for inactive user: "
+ stateChangeMember.getCallSign() + " / " + stateChangeMember.getChatCategory());
}
@@ -1552,7 +1567,7 @@ public class MessageBusManagementThread extends Thread {
if (splittedMessageLine[0].contains(USERINFOUPDATEORUSERISBACK)) {
if (splittedMessageLine.length < 6) {
- System.out.println("[MSGBUSMGT, warning:] Malformed UM3 message ignored: "
+ LOGGER.warning("Malformed UM3 message ignored: "
+ messageToProcess.getMessageText());
} else {
ChatMember stateChangeMember = new ChatMember();
@@ -1584,7 +1599,7 @@ public class MessageBusManagementThread extends Thread {
if (updatedActiveMember) {
this.client.getDbHandler().storeChatMember(stateChangeMember); // TODO: not clean, it should be an update
} else {
- System.out.println("[MSGBUSMGT, Info:] UM3 ignored for inactive user: "
+ LOGGER.info("UM3 ignored for inactive user: "
+ stateChangeMember.getCallSign() + " / " + stateChangeMember.getChatCategory());
}
}
@@ -1658,7 +1673,8 @@ public class MessageBusManagementThread extends Thread {
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
- System.out.println("Historic message directed to me: " + newMessageArrived.getReceiver().getCallSign() + ".");
+ LOGGER.fine("Historic message directed to local callsign: "
+ + newMessageArrived.getReceiver().getCallSign());
} else if (newMessageArrived.getSender().getCallSign().toUpperCase()
.equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) {
@@ -1710,9 +1726,9 @@ public class MessageBusManagementThread extends Thread {
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
}
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
- System.out.println("MSGBS bgfx, <<>>: referenced user left the chat during messageprocessing or message got before user entered chat message: "
- + referenceDeletedByUserLeftChatDuringMessageprocessing.getMessage());
- referenceDeletedByUserLeftChatDuringMessageprocessing.printStackTrace();
+ LOGGER.log(Level.WARNING,
+ "Referenced user disappeared while processing a historic message",
+ referenceDeletedByUserLeftChatDuringMessageprocessing);
}
// sdtout to me message-List
@@ -1721,12 +1737,13 @@ public class MessageBusManagementThread extends Thread {
try {
- System.out.println("[MSGBUSMGT:] processed message: " + newMessageArrived.getChatCategory().getCategoryNumber()
+ LOGGER.fine("Processed historic message: " + newMessageArrived.getChatCategory().getCategoryNumber()
+ " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> "
+ newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText());
} catch (Exception exceptionOccured) {
- System.out.println("[MSGMgtBus: ERROR CHATCHED ON MAYBE NULL ISSUE]: " + exceptionOccured.getMessage());
- exceptionOccured.printStackTrace();
+ LOGGER.log(Level.WARNING,
+ "Could not format historic-message diagnostic",
+ exceptionOccured);
}
// --- Band/QRG recognition (fills ChatMember.knownActiveBands) ---
@@ -1742,8 +1759,8 @@ public class MessageBusManagementThread extends Thread {
*/
if (SRVR_USERLISTEND.equals(opcode)) {
if (splittedMessageLine.length < 2) {
- System.out.println(
- "[MSGBUSMGT, Warning:] Ignoring malformed UE frame: "
+ LOGGER.warning(
+ "Ignoring malformed UE frame: "
+ messageToProcess.getMessageText());
return;
}
@@ -1767,7 +1784,7 @@ public class MessageBusManagementThread extends Thread {
//-> LOGSTAT|114|Wrong password!|
if (splittedMessageLine[0].contains(SRVR_LOGSTAT) && splittedMessageLine.length <= 5) {
- System.out.println("Passwort falsch!");
+ LOGGER.warning("ON4KST reported an invalid password");
if (splittedMessageLine[2].contains("password")) {
splittedMessageLine[2] += " pse disc- and reconnect";
@@ -1815,9 +1832,8 @@ public class MessageBusManagementThread extends Thread {
// + messageToProcess.getMessageText() + "\n");
// bufwrtrDBGMSGOut.flush();
- System.out.print(new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
- + " [MSGBUSMGT:] Critical, detected unhandled Chatmessage -> "
- + messageToProcess.getMessageText() + "\n");
+ LOGGER.warning("Detected unhandled ON4KST frame: "
+ + messageToProcess.getMessageText());
}
@@ -1915,7 +1931,7 @@ public class MessageBusManagementThread extends Thread {
} else if (categoryNumber.equals(this.client.getChatCategorySecondChat().getCategoryNumber() + "")) {
return this.client.getChatCategorySecondChat();
} else {
- System.out.println("Msgbusmgt: ERROR!!! -> category for this message does not exist!");
+ LOGGER.warning("Category for ON4KST message does not exist; using established main-category fallback");
return this.client.getChatCategoryMain(); //Chatcategory default decision
}
@@ -1955,7 +1971,7 @@ public class MessageBusManagementThread extends Thread {
|| incoming.getSender().getCallSign() == null
|| incoming.getSender().getCallSign().isBlank()) {
- System.err.println(
+ LOGGER.warning(
"KST4Contest auto-answer skipped: "
+ "incoming message has no valid sender callsign."
);
@@ -1970,7 +1986,7 @@ public class MessageBusManagementThread extends Thread {
);
if (qrg == null || qrg.isBlank()) {
- System.err.println(
+ LOGGER.warning(
"KST4Contest QRG auto-answer skipped for "
+ incoming.getSender().getCallSign()
+ ": no QRG is available for chat category "
@@ -1987,7 +2003,7 @@ public class MessageBusManagementThread extends Thread {
.getMessageHandling_autoAnswerTextMainCat();
if (payload == null || payload.isBlank()) {
- System.err.println(
+ LOGGER.warning(
"KST4Contest generic auto-answer skipped for "
+ incoming.getSender().getCallSign()
+ ": the configured answer text is empty."
@@ -2013,7 +2029,7 @@ public class MessageBusManagementThread extends Thread {
return On4KstProtocol.messageText(messageText);
} catch (IllegalArgumentException invalidMessage) {
- System.err.println(
+ LOGGER.warning(
"KST4Contest auto-answer skipped for "
+ incoming.getSender().getCallSign()
+ ": "
@@ -2166,7 +2182,7 @@ public class MessageBusManagementThread extends Thread {
// }
if (messageTextRaw.getMessageText() == null) {
- System.out.println("[MSGBUSMGT:] ERROR! got NULL message! BYE!");
+ LOGGER.severe("Received ON4KST message with null payload");
// this.interrupt();
// break;
}
@@ -2184,35 +2200,38 @@ public class MessageBusManagementThread extends Thread {
if (!On4KstProtocol.isInternalDxqResponse(
messageTextRaw.getMessageText())) {
- System.out.println(messageTextRaw.getMessageText() + " <- RXed"); // Stdout at
- // Console#######################################################TODO:Wichtig
+ LOGGER.log(Level.FINE, "ON4KST RX: {0}",
+ messageTextRaw.getMessageText());
}
try {
processRXMessage23001(messageTextRaw);
} catch (IOException e) {
- System.out.println("MsgBusMgt: process23001 went wrong / IO Error");
- e.printStackTrace();
+ LOGGER.log(Level.WARNING,
+ "I/O error while processing ON4KST frame", e);
} catch (SQLException e) {
- System.out.println("MsgBusMgt: process23001 went wrong / SQL Error");
- e.printStackTrace();
+ LOGGER.log(Level.WARNING,
+ "SQL error while processing ON4KST frame", e);
} catch (RuntimeException e) {
- System.out.println("MsgBusMgt: process23001 went wrong / Runtime Error while processing: "
- + messageTextRaw.getMessageText());
- e.printStackTrace();
+ LOGGER.log(Level.WARNING,
+ "Runtime error while processing ON4KST opcode "
+ + On4KstProtocol.opcode(
+ messageTextRaw.getMessageText()),
+ e);
}
}
} catch (InterruptedException e1) {
this.interrupt();
- e1.printStackTrace();
+ LOGGER.log(Level.FINE,
+ "ON4KST message processor interrupted", e1);
break;// TODO Change at may24, avoid uncloadability. Check if this could lead to further errors on instable link!
// client.getMessageRXBus().clear();
}
} // while true end
- System.out.println("Msgbusmgt: interrupt");
+ LOGGER.fine("ON4KST message processor stopped");
this.interrupt();
}
}
diff --git a/src/main/java/kst4contest/controller/MessageHistoryRecorder.java b/src/main/java/kst4contest/controller/MessageHistoryRecorder.java
new file mode 100644
index 00000000..3ab0c4a8
--- /dev/null
+++ b/src/main/java/kst4contest/controller/MessageHistoryRecorder.java
@@ -0,0 +1,238 @@
+package kst4contest.controller;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Records the raw ON4KST transport frames of one application session.
+ *
+ * The recorder is shared by the reader and writer threads. Every event is
+ * written under one lock so concurrent RX and TX traffic cannot interleave.
+ * File-system failures are contained here and never propagated to connection
+ * threads.
+ */
+public final class MessageHistoryRecorder implements AutoCloseable {
+ /** File name of the active program-session history. */
+ /* package */ static final String CURRENT_FILE_NAME = "Messagehistory.raw";
+
+ /** Component logger. */
+ private static final Logger LOGGER =
+ Logger.getLogger(MessageHistoryRecorder.class.getName());
+ /** Timestamp format for individual history events. */
+ private static final DateTimeFormatter EVENT_TIMESTAMP =
+ DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'")
+ .withZone(ZoneOffset.UTC);
+ /** Timestamp format used in archived history file names. */
+ private static final DateTimeFormatter ARCHIVE_TIMESTAMP =
+ DateTimeFormatter.ofPattern("uuuuMMdd-HHmmss'Z'")
+ .withZone(ZoneOffset.UTC);
+ /** Shared recorder instance that deliberately discards all frames. */
+ private static final MessageHistoryRecorder NO_OP_RECORDER =
+ new MessageHistoryRecorder();
+
+ /** Serializes state transitions and complete line writes. */
+ private final ReentrantLock lock = new ReentrantLock();
+ /** Directory containing the active and archived history files. */
+ private final Path appDirectory;
+ /** Path of the active program-session history. */
+ private final Path currentFile;
+ /** Time source for event and archive timestamps. */
+ private final Clock clock;
+ /** Whether this instance is the shared no-op recorder. */
+ private final boolean noOp;
+
+ /** Whether new frames should currently be recorded. */
+ private boolean enabled;
+ /** Whether this program session has completed its one-time rotation. */
+ private boolean sessionReady;
+ /** Whether opening the active file has already been attempted. */
+ private boolean openAttempted;
+ /** Writer for the active history file, or {@code null} while closed. */
+ private BufferedWriter writer;
+
+ private MessageHistoryRecorder() {
+ appDirectory = null;
+ currentFile = null;
+ clock = Clock.systemUTC();
+ noOp = true;
+ }
+
+ /**
+ * Creates a recorder for one application program session.
+ *
+ * @param appDirectory directory containing the current and archived history
+ * files
+ * @param enabled whether recording starts immediately
+ * @param clock time source used for event and archive timestamps
+ */
+ public MessageHistoryRecorder(
+ final Path appDirectory,
+ final boolean enabled,
+ final Clock clock
+ ) {
+ this.appDirectory = appDirectory;
+ this.currentFile = appDirectory.resolve(CURRENT_FILE_NAME);
+ this.clock = clock;
+ this.noOp = false;
+ setEnabled(enabled);
+ }
+
+ /** Returns a recorder that deliberately discards every frame. */
+ /* package */ static MessageHistoryRecorder disabled() {
+ return NO_OP_RECORDER;
+ }
+
+ /** Enables or disables recording immediately for the current program session. */
+ public void setEnabled(final boolean requestedEnabled) {
+ lock.lock();
+ try {
+ if (!noOp && enabled != requestedEnabled) {
+ enabled = requestedEnabled;
+ if (requestedEnabled) {
+ if (!sessionReady) {
+ openAttempted = false;
+ }
+ ensureWriter();
+ } else {
+ closeWriter();
+ openAttempted = false;
+ }
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /** Records one frame received from ON4KST without its transport CR/LF. */
+ public void recordRx(final String frame) {
+ record("RX", frame);
+ }
+
+ /** Records one frame successfully sent to ON4KST without its transport CR/LF. */
+ public void recordTx(final String frame) {
+ record("TX", frame);
+ }
+
+ private void record(final String direction, final String frame) {
+ lock.lock();
+ try {
+ if (enabled && !noOp) {
+ ensureWriter();
+ if (writer != null) {
+ final String safeFrame =
+ On4KstProtocol.redactSecretsForRecording(frame);
+ try {
+ writer.write(EVENT_TIMESTAMP.format(Instant.now(clock)));
+ writer.write('\t');
+ writer.write(direction);
+ writer.write('\t');
+ writer.write(safeFrame == null ? "" : safeFrame);
+ writer.newLine();
+ writer.flush();
+ } catch (IOException exception) {
+ if (LOGGER.isLoggable(Level.WARNING)) {
+ LOGGER.log(Level.WARNING,
+ "Could not write ON4KST message history; "
+ + "recording is disabled until it "
+ + "is enabled again",
+ exception);
+ }
+ closeWriter();
+ openAttempted = false;
+ enabled = false;
+ }
+ }
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private void ensureWriter() {
+ if (!enabled || writer != null || openAttempted) {
+ return;
+ }
+ openAttempted = true;
+
+ try {
+ Files.createDirectories(appDirectory);
+ if (!sessionReady) {
+ rotatePreviousSession();
+ sessionReady = true;
+ }
+ writer = Files.newBufferedWriter(
+ currentFile,
+ StandardCharsets.UTF_8,
+ StandardOpenOption.CREATE,
+ StandardOpenOption.APPEND);
+ } catch (IOException | RuntimeException exception) {
+ if (LOGGER.isLoggable(Level.WARNING)) {
+ LOGGER.log(Level.WARNING,
+ "Could not initialize ON4KST message history at "
+ + currentFile,
+ exception);
+ }
+ closeWriter();
+ }
+ }
+
+ private void rotatePreviousSession() throws IOException {
+ if (!Files.isRegularFile(currentFile) || Files.size(currentFile) == 0L) {
+ return;
+ }
+
+ final String baseName = "Messagehistory-"
+ + ARCHIVE_TIMESTAMP.format(Instant.now(clock));
+ Path archive = appDirectory.resolve(baseName + ".raw");
+ int suffix = 1;
+ while (Files.exists(archive)) {
+ archive = appDirectory.resolve(
+ baseName + "-" + suffix + ".raw");
+ suffix++;
+ }
+ try {
+ Files.move(currentFile, archive, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException unsupported) {
+ Files.move(currentFile, archive);
+ }
+ }
+
+ private void closeWriter() {
+ if (writer == null) {
+ return;
+ }
+ try {
+ writer.close();
+ } catch (IOException exception) {
+ LOGGER.log(Level.WARNING,
+ "Could not close ON4KST message history", exception);
+ } finally {
+ writer = null;
+ }
+ }
+
+ /** Stops recording and closes the current history file. */
+ @Override
+ public void close() {
+ lock.lock();
+ try {
+ enabled = false;
+ closeWriter();
+ } finally {
+ lock.unlock();
+ }
+ }
+}
diff --git a/src/main/java/kst4contest/controller/On4KstConnectionManager.java b/src/main/java/kst4contest/controller/On4KstConnectionManager.java
index a4ff8367..2e4f9d32 100644
--- a/src/main/java/kst4contest/controller/On4KstConnectionManager.java
+++ b/src/main/java/kst4contest/controller/On4KstConnectionManager.java
@@ -265,16 +265,21 @@ final class On4KstConnectionManager {
receiveQueue,
transmitQueue);
+ MessageHistoryRecorder historyRecorder =
+ controller.getMessageHistoryRecorder();
+
ReadThread readThread = new ReadThread(
token, socket, receiveQueue, this::isActiveSession,
line -> onInboundActivity(token, line),
- failure -> onConnectionFailure(token, failure));
+ failure -> onConnectionFailure(token, failure),
+ historyRecorder);
WriteThread writeThread = new WriteThread(
token, socket, transmitQueue,
mainCategory,
this::isActiveSession,
failure -> onConnectionFailure(token, failure),
- controller::onOn4KstOutboundFrameRejected);
+ controller::onOn4KstOutboundFrameRejected,
+ historyRecorder);
MessageBusManagementThread messageProcessor =
new MessageBusManagementThread(
controller, controller, token, receiveQueue,
diff --git a/src/main/java/kst4contest/controller/On4KstProtocol.java b/src/main/java/kst4contest/controller/On4KstProtocol.java
index 41d00671..f5918c6a 100644
--- a/src/main/java/kst4contest/controller/On4KstProtocol.java
+++ b/src/main/java/kst4contest/controller/On4KstProtocol.java
@@ -42,6 +42,33 @@ final class On4KstProtocol {
+ "|25|0|1|" + Math.max(0L, lastMessageTimestamp) + "|0|";
}
+ /**
+ * Redacts credentials from a raw login frame while preserving its structure.
+ * All non-login frames are returned byte-for-character unchanged.
+ */
+ static String redactSecretsForRecording(String frame) {
+ if (frame == null || !("LOGINC".equals(opcode(frame))
+ || "LOGIN".equals(opcode(frame)))) {
+ return frame;
+ }
+
+ int opcodeSeparator = frame.indexOf('|');
+ if (opcodeSeparator < 0) {
+ return frame;
+ }
+ int callsignSeparator = frame.indexOf('|', opcodeSeparator + 1);
+ if (callsignSeparator < 0) {
+ return frame.substring(0, opcodeSeparator + 1) + "";
+ }
+ int passwordSeparator = frame.indexOf('|', callsignSeparator + 1);
+ if (passwordSeparator < 0) {
+ return frame.substring(0, callsignSeparator + 1) + "";
+ }
+ return frame.substring(0, callsignSeparator + 1)
+ + ""
+ + frame.substring(passwordSeparator);
+ }
+
/** Builds the settings-complete frame for the supplied chat category. */
static String settingsDone(int category) {
return "SDONE|" + category(category) + "|";
diff --git a/src/main/java/kst4contest/controller/ReadThread.java b/src/main/java/kst4contest/controller/ReadThread.java
index 1e86f554..487ad44f 100644
--- a/src/main/java/kst4contest/controller/ReadThread.java
+++ b/src/main/java/kst4contest/controller/ReadThread.java
@@ -30,6 +30,7 @@ public class ReadThread extends Thread {
private final LongPredicate sessionIsActive;
private final Consumer inboundActivity;
private final Consumer connectionFailure;
+ private final MessageHistoryRecorder historyRecorder;
private final BufferedReader reader;
/**
@@ -41,7 +42,8 @@ public class ReadThread extends Thread {
@Deprecated
public ReadThread(Socket socket, ChatController client) throws IOException {
this(0L, socket, client.getMessageRXBus(), ignored -> true,
- ignored -> { }, ignored -> { });
+ ignored -> { }, ignored -> { },
+ client.getMessageHistoryRecorder());
}
/**
@@ -62,6 +64,21 @@ public class ReadThread extends Thread {
LongPredicate sessionIsActive,
Consumer inboundActivity,
Consumer connectionFailure
+ ) throws IOException {
+ this(sessionId, socket, receiveQueue, sessionIsActive,
+ inboundActivity, connectionFailure,
+ MessageHistoryRecorder.disabled());
+ }
+
+ /** Creates a reader that reports every received frame to the recorder. */
+ public ReadThread(
+ long sessionId,
+ Socket socket,
+ LinkedBlockingQueue receiveQueue,
+ LongPredicate sessionIsActive,
+ Consumer inboundActivity,
+ Consumer connectionFailure,
+ MessageHistoryRecorder historyRecorder
) throws IOException {
this.sessionId = sessionId;
this.socket = socket;
@@ -69,6 +86,8 @@ public class ReadThread extends Thread {
this.sessionIsActive = sessionIsActive;
this.inboundActivity = inboundActivity;
this.connectionFailure = connectionFailure;
+ this.historyRecorder = historyRecorder == null
+ ? MessageHistoryRecorder.disabled() : historyRecorder;
this.reader = new BufferedReader(new InputStreamReader(
socket.getInputStream(), StandardCharsets.UTF_8));
}
@@ -86,6 +105,8 @@ public class ReadThread extends Thread {
throw new EOFException("ON4KST closed the TCP connection");
}
+ historyRecorder.recordRx(response);
+
if (!sessionIsActive.test(sessionId)) {
break;
}
diff --git a/src/main/java/kst4contest/controller/ScoreService.java b/src/main/java/kst4contest/controller/ScoreService.java
index f0ee713d..20fcfe37 100644
--- a/src/main/java/kst4contest/controller/ScoreService.java
+++ b/src/main/java/kst4contest/controller/ScoreService.java
@@ -285,7 +285,7 @@ public final class ScoreService {
member.setCurrentPriorityScore(score == null ? 0.0 : score);
}
- controller.fireUserListUpdate("Priority scores projected to ChatMember");
+ controller.fireUserListUpdate();
}
private void updateSelectedScoreFromSnapshot(ScoreSnapshot snap) {
diff --git a/src/main/java/kst4contest/controller/StatusUpdateListener.java b/src/main/java/kst4contest/controller/StatusUpdateListener.java
index 2c205c7b..91c38887 100644
--- a/src/main/java/kst4contest/controller/StatusUpdateListener.java
+++ b/src/main/java/kst4contest/controller/StatusUpdateListener.java
@@ -14,9 +14,17 @@ public interface StatusUpdateListener {
/**
- * Called on change if the userlist to update the UI (sort the chatmembers list)
+ * Called when the user list needs a UI refresh without a diagnostic reason.
*/
- void onUserListUpdated(String reason);
+ void onUserListUpdated();
+
+ /**
+ * Called on change if the userlist needs a UI update and the change has a
+ * useful diagnostic reason.
+ *
+ * @param reason short diagnostic reason for the refresh
+ */
+ void onUserListUpdated(final String reason);
// new: userlist-update
/**
diff --git a/src/main/java/kst4contest/controller/WriteThread.java b/src/main/java/kst4contest/controller/WriteThread.java
index 8f583c22..a04fd2d9 100644
--- a/src/main/java/kst4contest/controller/WriteThread.java
+++ b/src/main/java/kst4contest/controller/WriteThread.java
@@ -31,6 +31,7 @@ public class WriteThread extends Thread {
private final LongPredicate sessionIsActive;
private final Consumer connectionFailure;
private final Consumer rejectedFrame;
+ private final MessageHistoryRecorder historyRecorder;
private final BufferedWriter writer;
private final int defaultCategory;
@@ -45,7 +46,9 @@ public class WriteThread extends Thread {
this(0L, socket, client.getMessageTXBus(),
client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber(),
ignored -> true,
- ignored -> { }, System.out::println);
+ ignored -> { },
+ message -> LOGGER.log(Level.WARNING, message),
+ client.getMessageHistoryRecorder());
}
/**
@@ -68,6 +71,22 @@ public class WriteThread extends Thread {
LongPredicate sessionIsActive,
Consumer connectionFailure,
Consumer rejectedFrame
+ ) throws IOException {
+ this(sessionId, socket, transmitQueue, defaultCategory,
+ sessionIsActive, connectionFailure, rejectedFrame,
+ MessageHistoryRecorder.disabled());
+ }
+
+ /** Creates a writer that records each frame after a successful flush. */
+ public WriteThread(
+ long sessionId,
+ Socket socket,
+ LinkedBlockingQueue transmitQueue,
+ int defaultCategory,
+ LongPredicate sessionIsActive,
+ Consumer connectionFailure,
+ Consumer rejectedFrame,
+ MessageHistoryRecorder historyRecorder
) throws IOException {
this.sessionId = sessionId;
this.socket = socket;
@@ -76,6 +95,8 @@ public class WriteThread extends Thread {
this.sessionIsActive = sessionIsActive;
this.connectionFailure = connectionFailure;
this.rejectedFrame = rejectedFrame;
+ this.historyRecorder = historyRecorder == null
+ ? MessageHistoryRecorder.disabled() : historyRecorder;
this.writer = new BufferedWriter(new OutputStreamWriter(
socket.getOutputStream(), StandardCharsets.UTF_8));
}
@@ -147,6 +168,7 @@ public class WriteThread extends Thread {
writer.write(frame);
writer.write("\r\n");
writer.flush();
+ historyRecorder.recordTx(frame);
}
private boolean isPoisonPill(ChatMessage message) {
diff --git a/src/main/java/kst4contest/model/ChatPreferences.java b/src/main/java/kst4contest/model/ChatPreferences.java
index 5bcf7ad5..5e9936d4 100644
--- a/src/main/java/kst4contest/model/ChatPreferences.java
+++ b/src/main/java/kst4contest/model/ChatPreferences.java
@@ -55,7 +55,7 @@ public class ChatPreferences {
* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults.
*/
// private static final int CONFIG_VERSION = 2;
- public static final int CONFIG_VERSION = 7;
+ public static final int CONFIG_VERSION = 8;
// Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility.
private static final String TAG_CONFIG_VERSION = "configVersion";
@@ -80,6 +80,19 @@ public class ChatPreferences {
*/
public ChatPreferences() {
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, PREFERENCE_RESOURCE, PREFERENCES_FILE);
+ }
+
+ /**
+ * Creates preferences backed by an explicit file without touching the user
+ * profile. This is useful for isolated imports, tools and tests.
+ *
+ * @param preferencesFile preferences XML path
+ */
+ public ChatPreferences(Path preferencesFile) {
+ if (preferencesFile == null) {
+ throw new IllegalArgumentException("Preferences file must not be null");
+ }
+ storeAndRestorePreferencesFileName = preferencesFile.toString();
// lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB");
@@ -329,6 +342,7 @@ public class ChatPreferences {
boolean messageHandling_autoAnswerEnabledSecondCat = false;
boolean messageHandling_autoAnswerToQRGRequestEnabled;
+ private volatile boolean messageHandling_debugModeToFileEnabled = true;
/*********************************************************************************
*
@@ -379,6 +393,17 @@ public class ChatPreferences {
return messageHandling_autoAnswerEnabledSecondCat;
}
+ public boolean isMessageHandling_debugModeToFileEnabled() {
+ return messageHandling_debugModeToFileEnabled;
+ }
+
+ public void setMessageHandling_debugModeToFileEnabled(
+ boolean messageHandling_debugModeToFileEnabled
+ ) {
+ this.messageHandling_debugModeToFileEnabled =
+ messageHandling_debugModeToFileEnabled;
+ }
+
public void setMessageHandling_autoAnswerEnabledSecondCat(boolean messageHandling_autoAnswerEnabledSecondCat) {
this.messageHandling_autoAnswerEnabledSecondCat = messageHandling_autoAnswerEnabledSecondCat;
}
@@ -2015,6 +2040,12 @@ public class ChatPreferences {
autoAnswerToQrgRequestEnabled.setTextContent(isMessageHandling_autoAnswerToQRGRequestEnabled()+"");
messageHandling.appendChild(autoAnswerToQrgRequestEnabled);
+ Element debugModeToFileEnabled = doc.createElement(
+ "messageHandling_debugModeToFileEnabled");
+ debugModeToFileEnabled.setTextContent(
+ Boolean.toString(isMessageHandling_debugModeToFileEnabled()));
+ messageHandling.appendChild(debugModeToFileEnabled);
+
/****************************
* GUI BEHAVIOUR
***************************/
@@ -2921,6 +2952,7 @@ public class ChatPreferences {
* case messageHandling
*
***********************************************/
+ messageHandling_debugModeToFileEnabled = true;
Element messageHandlingEl = getFirstElement(doc, "messageHandling");
if (messageHandlingEl != null) {
messageHandling_autoAnswerTextMainCat = getText(
@@ -2950,6 +2982,10 @@ public class ChatPreferences {
messageHandling_autoAnswerEnabledSecondCat,
"messageHandling_autoAnswerEnabledSecondCat",
"autoAnswerEnabledSecondCat");
+ messageHandling_debugModeToFileEnabled = getBoolean(
+ messageHandlingEl,
+ messageHandling_debugModeToFileEnabled,
+ "messageHandling_debugModeToFileEnabled");
/*
* The user interface intentionally exposes one shared generic auto-answer
diff --git a/src/main/java/kst4contest/utils/ApplicationFileLogging.java b/src/main/java/kst4contest/utils/ApplicationFileLogging.java
new file mode 100644
index 00000000..b49e0f4b
--- /dev/null
+++ b/src/main/java/kst4contest/utils/ApplicationFileLogging.java
@@ -0,0 +1,120 @@
+package kst4contest.utils;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.FileHandler;
+import java.util.logging.Formatter;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+
+/** Controls the persistent application log without redirecting stdout/stderr. */
+public final class ApplicationFileLogging implements AutoCloseable {
+ /** Logger namespace owned by the application. */
+ private static final String APP_LOGGER_NAME = "kst4contest";
+
+ /** Runtime switch for additional application diagnostics. */
+ private final AtomicBoolean debugEnabled = new AtomicBoolean();
+ /** Strong reference to the root logger that owns the file handler. */
+ private final Logger rootLogger = Logger.getLogger("");
+ /** Strong reference to the application logger whose level is managed. */
+ private final Logger appLogger = Logger.getLogger(APP_LOGGER_NAME);
+ /** Application logger level that must be restored when this handler closes. */
+ private final Level previousAppLevel;
+ /** File handler installed on the root logger. */
+ private final Handler handler;
+
+ /**
+ * Installs one append-only file handler.
+ *
+ * @param logFile target log file
+ * @param debugEnabled whether INFO/FINE application diagnostics are included
+ * @throws IOException if the handler cannot be created
+ */
+ public ApplicationFileLogging(
+ final Path logFile,
+ final boolean debugEnabled
+ )
+ throws IOException {
+ final Path parent = logFile.toAbsolutePath().getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
+
+ this.debugEnabled.set(debugEnabled);
+ previousAppLevel = appLogger.getLevel();
+ appLogger.setLevel(Level.FINE);
+
+ final FileHandler fileHandler =
+ new FileHandler(logFile.toString(), true);
+ fileHandler.setLevel(Level.ALL);
+ fileHandler.setFilter(this::isLoggable);
+ fileHandler.setFormatter(new UtcLogFormatter());
+ handler = fileHandler;
+ rootLogger.addHandler(handler);
+ }
+
+ /** Applies the runtime debug preference to subsequent file log records. */
+ public void setDebugEnabled(final boolean enabled) {
+ debugEnabled.set(enabled);
+ }
+
+ private boolean isLoggable(final LogRecord record) {
+ boolean loggable = record.getThrown() != null
+ || record.getLevel().intValue() >= Level.WARNING.intValue();
+ if (!loggable) {
+ final String loggerName = record.getLoggerName();
+ final boolean appRecord = loggerName != null
+ && (APP_LOGGER_NAME.equals(loggerName)
+ || loggerName.startsWith(APP_LOGGER_NAME + "."));
+ loggable = debugEnabled.get()
+ && appRecord
+ && record.getLevel().intValue() >= Level.FINE.intValue();
+ }
+ return loggable;
+ }
+
+ /** Removes and closes this handler. */
+ @Override
+ public void close() {
+ rootLogger.removeHandler(handler);
+ handler.close();
+ appLogger.setLevel(previousAppLevel);
+ }
+
+ /** Formats file-log records with comparable UTC millisecond timestamps. */
+ private static final class UtcLogFormatter extends Formatter {
+ /** Timestamp format for every file-log record. */
+ private static final DateTimeFormatter TIMESTAMP =
+ DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'")
+ .withZone(ZoneOffset.UTC);
+
+ @Override
+ public String format(final LogRecord record) {
+ final StringBuilder line = new StringBuilder()
+ .append(TIMESTAMP.format(Instant.ofEpochMilli(
+ record.getMillis())))
+ .append('\t')
+ .append(record.getLevel().getName())
+ .append('\t')
+ .append(record.getLoggerName())
+ .append('\t')
+ .append(formatMessage(record))
+ .append(System.lineSeparator());
+ if (record.getThrown() != null) {
+ final StringWriter stackTrace = new StringWriter();
+ record.getThrown().printStackTrace(new PrintWriter(stackTrace));
+ line.append(stackTrace);
+ }
+ return line.toString();
+ }
+ }
+}
diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java
index 91debdc6..d85cca3e 100644
--- a/src/main/java/kst4contest/view/Kst4ContestApplication.java
+++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java
@@ -8,10 +8,8 @@ import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.util.*;
-import java.util.logging.FileHandler;
import java.util.logging.Level;
import java.util.logging.Logger;
-import java.util.logging.SimpleFormatter;
import java.util.function.Consumer;
import java.util.function.Predicate;
@@ -68,6 +66,7 @@ import javafx.scene.shape.Polygon;
import javafx.stage.Screen;
import kst4contest.logic.BandOpportunityResolver;
+import kst4contest.utils.ApplicationFileLogging;
import kst4contest.utils.ApplicationFileUtils;
import kst4contest.view.map.StationMapBridge;
import kst4contest.view.map.StationMapView;
@@ -84,6 +83,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
private static final Logger LOGGER = Logger.getLogger(
Kst4ContestApplication.class.getName());
+ private static ApplicationFileLogging fileLogging;
private static final String SIMPLE_LOG_MANUAL_URL =
"https://kst4contest.hamradioonline.de/manual/en/log-sync/"
+ "#method-1-universal-file-based-callsign-interpreter-simplelogfile";
@@ -6229,7 +6229,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
public void stop() {
- System.out.println("[Main.java, Info:] Stage is closing, killing all resources");
+ LOGGER.info("Application is shutting down and closing all resources");
if (layoutAutosave != null) {
layoutAutosave.flushPending();
}
@@ -6245,8 +6245,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
try {
chatcontroller.disconnect("CLOSEALL");
} catch (Exception e) {
- System.out.println("[Main.java, Warning:] Exception during disconnect: " + e.getMessage());
+ LOGGER.log(Level.WARNING, "Exception during disconnect", e);
}
+ chatcontroller.getMessageHistoryRecorder().close();
+ closeFileLogging();
// Platform.exit();
System.exit(0);
@@ -6683,6 +6685,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ChatMember ownChatMemberObject = new ChatMember();
chatcontroller = new ChatController(ownChatMemberObject, this); // instantiate the Chatcontroller with the user object
+ setDebugFileLoggingEnabled(chatcontroller.getChatPreferences()
+ .isMessageHandling_debugModeToFileEnabled());
layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences());
messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences());
chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual
@@ -11475,6 +11479,31 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
grdPnlMessageHandlingBeacon.add(chkbx_messageHandlingAutoQRGInfoEnabled, 0, 2, 2, 1);
+ grdPnlMessageHandlingBeacon.add(generateLabeledSeparator(
+ 100, "Debug and contest history"), 0, 3, 2, 1);
+
+ CheckBox chkbxDebugModeToFile =
+ new CheckBox("Enable debug mode to file");
+ chkbxDebugModeToFile.setSelected(chatcontroller.getChatPreferences()
+ .isMessageHandling_debugModeToFileEnabled());
+ chkbxDebugModeToFile.setTooltip(new Tooltip(
+ "Records complete ON4KST RX/TX session traffic in Messagehistory.raw "
+ + "and adds diagnostic details to kst4contest-errors.log.\n"
+ + "Login passwords are redacted. Warnings and errors are always logged."));
+ chkbxDebugModeToFile.selectedProperty().addListener(
+ (observable, oldValue, enabled) -> {
+ chatcontroller.getChatPreferences()
+ .setMessageHandling_debugModeToFileEnabled(enabled);
+ if (enabled) {
+ setDebugFileLoggingEnabled(true);
+ chatcontroller.setMessageHistoryRecordingEnabled(true);
+ } else {
+ chatcontroller.setMessageHistoryRecordingEnabled(false);
+ setDebugFileLoggingEnabled(false);
+ }
+ });
+ grdPnlMessageHandlingBeacon.add(chkbxDebugModeToFile, 0, 4, 2, 1);
+
VBox vbxMsgHandlBeacon = new VBox();
vbxMsgHandlBeacon.setPadding(new Insets(10, 10, 10, 10));
vbxMsgHandlBeacon.getChildren().addAll(grdPnlMessageHandlingBeacon);
@@ -12218,20 +12247,33 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
private static void setupFileLogging() {
+ if (fileLogging != null) {
+ return;
+ }
try {
- String logDir = Path.of(System.getProperty("user.home"), ".praktiKST").toString();
- new File(logDir).mkdirs();
- FileHandler fileHandler = new FileHandler(logDir + "/kst4contest-errors.log", true);
- // Connection loss/reconnect diagnostics are warnings, not fatal crashes.
- fileHandler.setLevel(Level.WARNING);
- fileHandler.setFormatter(new SimpleFormatter());
- Logger rootLogger = Logger.getLogger("");
- rootLogger.addHandler(fileHandler);
+ Path logFile = Path.of(ApplicationFileUtils.getFilePath(
+ ApplicationConstants.APPLICATION_NAME,
+ "kst4contest-errors.log"));
+ // Start conservatively until the persisted preference has been loaded.
+ fileLogging = new ApplicationFileLogging(logFile, false);
} catch (IOException e) {
System.err.println("Could not set up file logging: " + e.getMessage());
}
}
+ private static void setDebugFileLoggingEnabled(boolean enabled) {
+ if (fileLogging != null) {
+ fileLogging.setDebugEnabled(enabled);
+ }
+ }
+
+ private static void closeFileLogging() {
+ if (fileLogging != null) {
+ fileLogging.close();
+ fileLogging = null;
+ }
+ }
+
@Override
public void onThreadStatusChanged(
String key,
@@ -12351,13 +12393,27 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
@Override
- public void onUserListUpdated(String reason) {
+ public void onUserListUpdated() {
+ scheduleUserListRefresh(null);
+ }
+
+ @Override
+ public void onUserListUpdated(final String reason) {
+ scheduleUserListRefresh(reason);
+ }
+
+ private void scheduleUserListRefresh(final String diagnosticReason) {
Platform.runLater(() -> {
- pendingUserListUpdateReason = reason;
+ if (diagnosticReason != null && !diagnosticReason.isBlank()) {
+ pendingUserListUpdateReason = diagnosticReason;
+ }
if (userListRefreshCoalescer == null) {
userListRefreshCoalescer = new PauseTransition(Duration.millis(300));
userListRefreshCoalescer.setOnFinished(event -> {
+ final String completedDiagnosticReason = pendingUserListUpdateReason;
+ pendingUserListUpdateReason = "";
+
forceChatMemberFilterRefresh();
if (tbl_chatMember != null) {
@@ -12366,7 +12422,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
refreshStationMapIfVisible();
- System.out.println("KST4Capp, UI Update Trigger: " + pendingUserListUpdateReason);
+ if (!completedDiagnosticReason.isEmpty()) {
+ System.out.println("KST4Capp, UI Update Trigger: "
+ + completedDiagnosticReason);
+ }
});
}
diff --git a/src/main/resources/praktiKSTpreferences.xml b/src/main/resources/praktiKSTpreferences.xml
index fe33df37..4656544d 100644
--- a/src/main/resources/praktiKSTpreferences.xml
+++ b/src/main/resources/praktiKSTpreferences.xml
@@ -1,5 +1,6 @@
+ 8
DO5AMF
changeMe
@@ -128,6 +129,7 @@
Hi, sry I am not qrv, just testing new features of KST4Contest 1.251
false
true
+ true
false
@@ -146,4 +148,4 @@
0.5749241274658573
0.1754658301944221
-
\ No newline at end of file
+
diff --git a/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java b/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java
new file mode 100644
index 00000000..5c58ffbb
--- /dev/null
+++ b/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java
@@ -0,0 +1,159 @@
+package kst4contest.controller;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class MessageHistoryRecorderTest {
+ private static final Instant FIXED_INSTANT =
+ Instant.parse("2026-10-03T10:15:27.384Z");
+
+ @TempDir
+ Path temporaryDirectory;
+
+ @Test
+ void disabledRecorderDoesNotCreateOrWriteHistory() throws Exception {
+ try (MessageHistoryRecorder recorder = recorder(false)) {
+ recorder.recordRx("OK|");
+ recorder.recordTx("CK|");
+ }
+
+ assertFalse(Files.exists(historyFile()));
+ }
+
+ @Test
+ void writesRxAndTxAsThreeTabSeparatedFieldsWithUtcMilliseconds()
+ throws Exception {
+ try (MessageHistoryRecorder recorder = recorder(true)) {
+ recorder.recordRx("CH|2|raw\tframe|");
+ recorder.recordTx("MSG|2|0|hello|0|");
+ }
+
+ assertEquals(List.of(
+ "2026-10-03T10:15:27.384Z\tRX\tCH|2|raw\tframe|",
+ "2026-10-03T10:15:27.384Z\tTX\tMSG|2|0|hello|0|"),
+ Files.readAllLines(historyFile()));
+ }
+
+ @Test
+ void redactsOnlyThePasswordFieldOfLoginFrames() throws Exception {
+ try (MessageHistoryRecorder recorder = recorder(true)) {
+ recorder.recordTx(
+ "LOGINC|DL1ABC|top-secret|2|KST4Contest|25|0|1|0|0|");
+ recorder.recordTx("MSG|2|0|top-secret and LOGINC in chat|0|");
+ }
+
+ List lines = Files.readAllLines(historyFile());
+ assertTrue(lines.get(0).endsWith(
+ "\tTX\tLOGINC|DL1ABC||2|KST4Contest|25|0|1|0|0|"));
+ assertFalse(lines.get(0).contains("top-secret"));
+ assertTrue(lines.get(1).endsWith(
+ "\tTX\tMSG|2|0|top-secret and LOGINC in chat|0|"));
+ }
+
+ @Test
+ void concurrentRxAndTxWritesNeverMixLines() throws Exception {
+ int eventCount = 200;
+ ExecutorService executor = Executors.newFixedThreadPool(8);
+ CountDownLatch start = new CountDownLatch(1);
+ try (MessageHistoryRecorder recorder = recorder(true)) {
+ for (int index = 0; index < eventCount; index++) {
+ int event = index;
+ executor.submit(() -> {
+ start.await();
+ if (event % 2 == 0) {
+ recorder.recordRx("RXFRAME|" + event + "|");
+ } else {
+ recorder.recordTx("TXFRAME|" + event + "|");
+ }
+ return null;
+ });
+ }
+ start.countDown();
+ executor.shutdown();
+ assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
+ } finally {
+ executor.shutdownNow();
+ }
+
+ List lines = Files.readAllLines(historyFile());
+ assertEquals(eventCount, lines.size());
+ Set events = new HashSet<>();
+ for (String line : lines) {
+ String[] fields = line.split("\t", 3);
+ assertEquals(3, fields.length);
+ assertTrue(fields[1].equals("RX") || fields[1].equals("TX"));
+ String[] frame = fields[2].split("\\|");
+ events.add(Integer.parseInt(frame[1]));
+ }
+ assertEquals(eventCount, events.size());
+ }
+
+ @Test
+ void rotatesOncePerProgramSessionAndReenableAppendsCurrentFile()
+ throws Exception {
+ Files.writeString(historyFile(), "previous session\n");
+
+ try (MessageHistoryRecorder recorder = recorder(true)) {
+ recorder.recordRx("FIRST|");
+ recorder.setEnabled(false);
+ recorder.setEnabled(true);
+ recorder.recordTx("SECOND|");
+ }
+
+ List archives = new ArrayList<>();
+ try (var files = Files.list(temporaryDirectory)) {
+ files.filter(path -> path.getFileName().toString()
+ .matches("Messagehistory-\\d{8}-\\d{6}Z(?:-\\d+)?\\.raw"))
+ .forEach(archives::add);
+ }
+ assertEquals(1, archives.size());
+ assertEquals("previous session\n", Files.readString(archives.get(0)));
+ assertEquals(2, Files.readAllLines(historyFile()).size());
+ }
+
+ @Test
+ void ioFailureNeverEscapesToConnectionThreads() throws Exception {
+ Path applicationPathThatIsAFile = temporaryDirectory.resolve("not-a-dir");
+ Files.writeString(applicationPathThatIsAFile, "occupied");
+
+ assertDoesNotThrow(() -> {
+ try (MessageHistoryRecorder recorder = new MessageHistoryRecorder(
+ applicationPathThatIsAFile,
+ true,
+ Clock.fixed(FIXED_INSTANT, ZoneOffset.UTC))) {
+ recorder.recordRx("OK|");
+ recorder.recordTx("CK|");
+ }
+ });
+ }
+
+ private MessageHistoryRecorder recorder(boolean enabled) {
+ return new MessageHistoryRecorder(
+ temporaryDirectory,
+ enabled,
+ Clock.fixed(FIXED_INSTANT, ZoneOffset.UTC));
+ }
+
+ private Path historyFile() {
+ return temporaryDirectory.resolve("Messagehistory.raw");
+ }
+}
diff --git a/src/test/java/kst4contest/controller/On4KstHistoryIntegrationTest.java b/src/test/java/kst4contest/controller/On4KstHistoryIntegrationTest.java
new file mode 100644
index 00000000..eab02391
--- /dev/null
+++ b/src/test/java/kst4contest/controller/On4KstHistoryIntegrationTest.java
@@ -0,0 +1,215 @@
+package kst4contest.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.TempDir;
+
+import kst4contest.model.ChatMessage;
+
+class On4KstHistoryIntegrationTest {
+
+ @TempDir
+ Path temporaryDirectory;
+
+ @Test
+ @Timeout(5)
+ void readerRecordsInternalOkBeforeFilteringIt() throws Exception {
+ try (MessageHistoryRecorder recorder = recorder();
+ ServerSocket server = new ServerSocket(0)) {
+ CompletableFuture serverDone = CompletableFuture.runAsync(() -> {
+ try (Socket accepted = server.accept();
+ OutputStreamWriter out = new OutputStreamWriter(
+ accepted.getOutputStream(), StandardCharsets.UTF_8)) {
+ out.write("OK|\r\n");
+ out.flush();
+ } catch (IOException exception) {
+ throw new RuntimeException(exception);
+ }
+ });
+
+ try (Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
+ LinkedBlockingQueue messages =
+ new LinkedBlockingQueue<>();
+ AtomicBoolean active = new AtomicBoolean(true);
+ ReadThread reader = new ReadThread(
+ 31L, client, messages, ignored -> active.get(),
+ ignored -> { }, ignored -> { }, recorder);
+ reader.start();
+
+ awaitHistoryLines(1);
+ assertNull(messages.poll(200, TimeUnit.MILLISECONDS));
+ active.set(false);
+ reader.join(Duration.ofSeconds(2).toMillis());
+ }
+ serverDone.get(2, TimeUnit.SECONDS);
+ }
+
+ assertTrue(Files.readAllLines(historyFile()).get(0)
+ .endsWith("\tRX\tOK|"));
+ }
+
+ @Test
+ @Timeout(5)
+ void writerRecordsOnlySuccessfullyFlushedFrames() throws Exception {
+ try (MessageHistoryRecorder recorder = recorder();
+ ServerSocket server = new ServerSocket(0)) {
+ CompletableFuture received = CompletableFuture.supplyAsync(() -> {
+ try (Socket accepted = server.accept()) {
+ return new String(
+ accepted.getInputStream().readNBytes(18),
+ StandardCharsets.UTF_8);
+ } catch (IOException exception) {
+ throw new RuntimeException(exception);
+ }
+ });
+
+ try (Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
+ LinkedBlockingQueue queue = new LinkedBlockingQueue<>();
+ AtomicBoolean active = new AtomicBoolean(true);
+ WriteThread writer = new WriteThread(
+ 32L, client, queue, 2, ignored -> active.get(),
+ ignored -> { }, ignored -> { }, recorder);
+ writer.start();
+ queue.add(chatMessage("hello"));
+
+ assertEquals("MSG|2|0|hello|0|\r\n",
+ received.get(2, TimeUnit.SECONDS));
+ awaitHistoryLines(1);
+ active.set(false);
+ writer.interrupt();
+ writer.join(Duration.ofSeconds(2).toMillis());
+ }
+ }
+
+ assertTrue(Files.readAllLines(historyFile()).get(0)
+ .endsWith("\tTX\tMSG|2|0|hello|0|"));
+ }
+
+ @Test
+ @Timeout(5)
+ void rejectedAndFailedWritesAreNotRecordedAsTx() throws Exception {
+ try (MessageHistoryRecorder recorder = recorder()) {
+ LinkedBlockingQueue rejectedQueue =
+ new LinkedBlockingQueue<>();
+ AtomicBoolean rejectedActive = new AtomicBoolean(true);
+ CompletableFuture rejection = new CompletableFuture<>();
+ try (Socket socket = new FlushFailingSocket(false)) {
+ WriteThread writer = new WriteThread(
+ 33L, socket, rejectedQueue, 2,
+ ignored -> rejectedActive.get(), ignored -> { },
+ rejection::complete, recorder);
+ writer.start();
+ rejectedQueue.add(serverFrame("BROKEN\rFRAME"));
+ rejection.get(2, TimeUnit.SECONDS);
+ rejectedActive.set(false);
+ writer.interrupt();
+ writer.join(Duration.ofSeconds(2).toMillis());
+ }
+
+ LinkedBlockingQueue failedQueue =
+ new LinkedBlockingQueue<>();
+ CompletableFuture failure = new CompletableFuture<>();
+ try (Socket socket = new FlushFailingSocket(true)) {
+ WriteThread writer = new WriteThread(
+ 34L, socket, failedQueue, 2,
+ ignored -> true, failure::complete,
+ ignored -> { }, recorder);
+ writer.start();
+ failedQueue.add(chatMessage("not-sent"));
+ failure.get(2, TimeUnit.SECONDS);
+ writer.join(Duration.ofSeconds(2).toMillis());
+ }
+ }
+
+ assertTrue(Files.exists(historyFile()));
+ assertTrue(Files.readAllLines(historyFile()).isEmpty());
+ }
+
+ private MessageHistoryRecorder recorder() {
+ return new MessageHistoryRecorder(
+ temporaryDirectory,
+ true,
+ Clock.fixed(
+ Instant.parse("2026-10-03T10:15:27.384Z"),
+ ZoneOffset.UTC));
+ }
+
+ private Path historyFile() {
+ return temporaryDirectory.resolve("Messagehistory.raw");
+ }
+
+ private void awaitHistoryLines(int expected) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
+ while (System.nanoTime() < deadline) {
+ if (Files.exists(historyFile())
+ && Files.readAllLines(historyFile()).size() >= expected) {
+ return;
+ }
+ TimeUnit.MILLISECONDS.sleep(10);
+ }
+ assertEquals(expected,
+ Files.exists(historyFile())
+ ? Files.readAllLines(historyFile()).size() : 0);
+ }
+
+ private ChatMessage chatMessage(String text) {
+ ChatMessage message = new ChatMessage();
+ message.setMessageText(text);
+ return message;
+ }
+
+ private ChatMessage serverFrame(String text) {
+ ChatMessage message = new ChatMessage();
+ message.setMessageDirectedToServer(true);
+ message.setMessageText(text);
+ return message;
+ }
+
+ private static final class FlushFailingSocket extends Socket {
+ private final boolean failOnFlush;
+
+ private FlushFailingSocket(boolean failOnFlush) {
+ this.failOnFlush = failOnFlush;
+ }
+
+ @Override
+ public OutputStream getOutputStream() {
+ return new OutputStream() {
+ @Override
+ public void write(int value) {
+ // The test socket intentionally discards all bytes.
+ }
+
+ @Override
+ public void flush() throws IOException {
+ if (failOnFlush) {
+ throw new IOException("simulated flush failure");
+ }
+ }
+ };
+ }
+ }
+}
diff --git a/src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java b/src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java
new file mode 100644
index 00000000..31128a98
--- /dev/null
+++ b/src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java
@@ -0,0 +1,86 @@
+package kst4contest.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockingDetails;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.invocation.Invocation;
+
+import javafx.application.Platform;
+import kst4contest.logic.PriorityCalculator;
+import kst4contest.model.ChatCategory;
+import kst4contest.model.ChatMember;
+
+class ScoreServiceRefreshTest {
+ private static final String REMOVED_DIAGNOSTIC =
+ "Priority scores projected to ChatMember";
+
+ @Test
+ void scoreProjectionRefreshesUserListWithoutPeriodicDiagnostic()
+ throws Exception {
+ ChatController controller = mock(ChatController.class);
+ ChatMember member = new ChatMember();
+ member.setCallSign("DL1ABC-2");
+ when(controller.snapshotChatMembers()).thenReturn(List.of(member));
+
+ ScoreService service = new ScoreService(
+ controller, mock(PriorityCalculator.class), 15);
+ ScoreService.ScoreSnapshot snapshot = new ScoreService.ScoreSnapshot(
+ 1L,
+ Map.of("DL1ABC", 42.5),
+ Map.of(),
+ List.of());
+
+ applySnapshot(service, snapshot);
+
+ assertEquals(42.5, member.getCurrentPriorityScore());
+ List controllerInvocations =
+ List.copyOf(mockingDetails(controller).getInvocations());
+ assertTrue(controllerInvocations.stream().anyMatch(invocation ->
+ invocation.getMethod().getName().equals("fireUserListUpdate")
+ && invocation.getArguments().length == 0));
+ assertFalse(controllerInvocations.stream().anyMatch(invocation ->
+ List.of(invocation.getArguments()).contains(REMOVED_DIAGNOSTIC)));
+ }
+
+ @Test
+ void userListRefreshKeepsDiagnosticAndReasonlessCallbacksSeparate() {
+ ChatController controller = mock(
+ ChatController.class,
+ org.mockito.Answers.CALLS_REAL_METHODS);
+ StatusUpdateListener listener = mock(StatusUpdateListener.class);
+ controller.setStatusListener(listener);
+
+ try (MockedStatic platform =
+ org.mockito.Mockito.mockStatic(Platform.class)) {
+ platform.when(Platform::isFxApplicationThread).thenReturn(true);
+
+ controller.fireUserListUpdate();
+ controller.fireUserListUpdate("Reachability calculated");
+ }
+
+ verify(listener).onUserListUpdated();
+ verify(listener).onUserListUpdated("Reachability calculated");
+ }
+
+ private void applySnapshot(
+ ScoreService service,
+ ScoreService.ScoreSnapshot snapshot
+ ) throws Exception {
+ Method method = ScoreService.class.getDeclaredMethod(
+ "applyScoreSnapshotToChatMembers",
+ ScoreService.ScoreSnapshot.class);
+ method.setAccessible(true);
+ method.invoke(service, snapshot);
+ }
+}
diff --git a/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java b/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java
new file mode 100644
index 00000000..e3251745
--- /dev/null
+++ b/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java
@@ -0,0 +1,70 @@
+package kst4contest.test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import kst4contest.model.ChatPreferences;
+
+class ChatPreferencesDebugModeTest {
+
+ @TempDir
+ Path temporaryDirectory;
+
+ @Test
+ void debugModeToFileIsEnabledByDefault() {
+ assertTrue(new ChatPreferences(
+ temporaryDirectory.resolve("unused-default.xml"))
+ .isMessageHandling_debugModeToFileEnabled());
+ }
+
+ @Test
+ void versionSevenWithoutDebugSettingKeepsDebugModeEnabled()
+ throws IOException {
+ Path preferencesFile = temporaryDirectory.resolve("version-seven.xml");
+ Files.writeString(preferencesFile, """
+
+
+ 7
+
+ false
+
+
+ """);
+
+ ChatPreferences restored = preferencesAt(preferencesFile);
+ restored.setMessageHandling_debugModeToFileEnabled(false);
+
+ assertTrue(restored.readPreferencesFromXmlFile());
+ assertTrue(restored.isMessageHandling_debugModeToFileEnabled());
+ }
+
+ @Test
+ void disabledDebugModeSurvivesFullXmlRoundTrip() throws IOException {
+ Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
+ ChatPreferences written = preferencesAt(preferencesFile);
+ written.setMessageHandling_debugModeToFileEnabled(false);
+
+ assertTrue(written.writePreferencesToXmlFile());
+
+ String writtenXml = Files.readString(preferencesFile);
+ assertTrue(writtenXml.contains("8"));
+ assertTrue(writtenXml.contains(
+ "false"
+ + ""));
+
+ ChatPreferences restored = preferencesAt(preferencesFile);
+ assertTrue(restored.readPreferencesFromXmlFile());
+ assertFalse(restored.isMessageHandling_debugModeToFileEnabled());
+ }
+
+ private ChatPreferences preferencesAt(Path preferencesFile) {
+ return new ChatPreferences(preferencesFile);
+ }
+}
diff --git a/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java b/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java
index e76af804..0e462a14 100644
--- a/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java
+++ b/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java
@@ -113,7 +113,7 @@ class ChatPreferencesLayoutPersistenceTest {
assertFalse(writtenXml.contains("UNSAVED-CALL"));
assertTrue(writtenXml.contains(""));
assertTrue(writtenXml.contains("untouched"));
- assertTrue(writtenXml.contains("7"));
+ assertTrue(writtenXml.contains("8"));
assertTrue(writtenXml.contains("812.0;1340.0"));
assertTrue(writtenXml.contains("false"
+ ""));
@@ -127,8 +127,6 @@ class ChatPreferencesLayoutPersistenceTest {
}
private ChatPreferences preferencesAt(Path preferencesFile) {
- ChatPreferences preferences = new ChatPreferences();
- preferences.setStoreAndRestorePreferencesFileName(preferencesFile.toString());
- return preferences;
+ return new ChatPreferences(preferencesFile);
}
}
diff --git a/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java b/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java
index 24fbb016..8b6c0fae 100644
--- a/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java
+++ b/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java
@@ -18,7 +18,9 @@ class ChatPreferencesStationMapClusteringTest {
@Test
void clusteringIsEnabledByDefault() {
- assertTrue(new ChatPreferences().isGUIstationMapClusteringEnabled());
+ assertTrue(new ChatPreferences(
+ temporaryDirectory.resolve("unused-default.xml"))
+ .isGUIstationMapClusteringEnabled());
}
@Test
@@ -30,7 +32,7 @@ class ChatPreferencesStationMapClusteringTest {
assertTrue(written.writePreferencesToXmlFile());
String writtenXml = Files.readString(preferencesFile);
- assertTrue(writtenXml.contains("7"));
+ assertTrue(writtenXml.contains("8"));
assertTrue(writtenXml.contains("false"
+ ""));
@@ -80,7 +82,7 @@ class ChatPreferencesStationMapClusteringTest {
Files.writeString(preferencesFile, """
- 7
+ 8
sometimes
@@ -94,8 +96,6 @@ class ChatPreferencesStationMapClusteringTest {
}
private ChatPreferences preferencesAt(Path preferencesFile) {
- ChatPreferences preferences = new ChatPreferences();
- preferences.setStoreAndRestorePreferencesFileName(preferencesFile.toString());
- return preferences;
+ return new ChatPreferences(preferencesFile);
}
}
diff --git a/src/test/java/kst4contest/utils/ApplicationFileLoggingTest.java b/src/test/java/kst4contest/utils/ApplicationFileLoggingTest.java
new file mode 100644
index 00000000..2b96388b
--- /dev/null
+++ b/src/test/java/kst4contest/utils/ApplicationFileLoggingTest.java
@@ -0,0 +1,50 @@
+package kst4contest.utils;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.UUID;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class ApplicationFileLoggingTest {
+
+ @TempDir
+ Path temporaryDirectory;
+
+ @Test
+ void warningsAreAlwaysLoggedAndDiagnosticsFollowDebugMode()
+ throws Exception {
+ Path logFile = temporaryDirectory.resolve("kst4contest-errors.log");
+ Logger logger = Logger.getLogger(
+ "kst4contest.test.filelogging." + UUID.randomUUID());
+
+ try (ApplicationFileLogging logging =
+ new ApplicationFileLogging(logFile, false)) {
+ logger.log(Level.INFO, "diagnostic while disabled");
+ logger.log(Level.WARNING, "warning while disabled");
+ logger.log(Level.INFO, "exception while disabled",
+ new IllegalStateException("simulated failure"));
+
+ logging.setDebugEnabled(true);
+ logger.log(Level.FINE, "fine diagnostic while enabled");
+ logger.log(Level.INFO, "info diagnostic while enabled");
+ logger.log(Level.SEVERE, "error while enabled");
+ }
+
+ String log = Files.readString(logFile);
+ assertFalse(log.contains("diagnostic while disabled"));
+ assertTrue(log.contains("warning while disabled"));
+ assertTrue(log.contains("exception while disabled"));
+ assertTrue(log.contains("simulated failure"));
+ assertTrue(log.contains("fine diagnostic while enabled"));
+ assertTrue(log.contains("info diagnostic while enabled"));
+ assertTrue(log.contains("error while enabled"));
+ assertTrue(log.contains("Z\tWARNING\t"));
+ }
+}