Add replayable ON4KST session logging

Record redacted RX/TX frames with session rotation and runtime debug controls.

Add targeted file diagnostics, tests, documentation, and a silent priority-score refresh path.
This commit is contained in:
Marc Froehlich committed 2026-10-03 20:50:19 +02:00
1 parent f9760bcbaa
commit 323c249bf7
26 files changed
+1329 -90

No files matched your search

+6
View File
@@ -20,6 +20,12 @@ debug.out
SimpleLogFile.txt SimpleLogFile.txt
udpReaderBackup.txt udpReaderBackup.txt
# Runtime/debug logs
Messagehistory.raw
Messagehistory-*.raw
kst4contest-errors.log
kst4contest-errors.log.lck
#tempfiles #tempfiles
.idea/ .idea/
out/ out/
+13 -2
View File
@@ -1,6 +1,6 @@
# KST4Contest Project Context # 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. 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 ## 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. - `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. - 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. - 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. - 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. - 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 `UTC<TAB>RX|TX<TAB>RAW_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 `<REDACTED>` 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 ## User Workflow / UI Invariants
- Contest operating speed and low-friction interaction are primary goals. - Contest operating speed and low-friction interaction are primary goals.
+2
View File
@@ -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. 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. In Datei- und Verzeichnisnamen wird teilweise noch der technische Name `praktiKST` verwendet. Gemeint ist dasselbe Programm.
--- ---
+23
View File
@@ -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. 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\<Benutzername>\.praktiKST\Messagehistory.raw`, unter Linux und macOS unter `~/.praktiKST/Messagehistory.raw`.
Jede Zeile enthält genau ein Ereignis:
```text
UTC<TAB>RX|TX<TAB>RAW_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 `<REDACTED>` 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 ### Schutz vor wiederholten Antworten
Jede automatisch erzeugte Nachricht trägt das feste Präfix: Jede automatisch erzeugte Nachricht trägt das feste Präfix:
+23
View File
@@ -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. 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\<YourName>\.praktiKST\Messagehistory.raw`; on Linux and macOS it is stored at `~/.praktiKST/Messagehistory.raw`.
Each line contains exactly one event:
```text
UTC<TAB>RX|TX<TAB>RAW_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 `<REDACTED>`.
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 ### Protection against repeated replies
Every automatically generated reply contains the fixed prefix: Every automatically generated reply contains the fixed prefix:
+2
View File
@@ -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. 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. Some file and directory names still use the technical name `praktiKST`. They refer to the same program.
--- ---
@@ -25,6 +25,7 @@ import kst4contest.logic.BandOpportunityResolver;
import kst4contest.logic.PriorityCalculator; import kst4contest.logic.PriorityCalculator;
import kst4contest.model.*; import kst4contest.model.*;
import kst4contest.test.MockKstServer; import kst4contest.test.MockKstServer;
import kst4contest.utils.ApplicationFileUtils;
import kst4contest.utils.PlayAudioUtils; import kst4contest.utils.PlayAudioUtils;
import kst4contest.locatorUtils.Location; import kst4contest.locatorUtils.Location;
import kst4contest.view.Kst4ContestApplication; import kst4contest.view.Kst4ContestApplication;
@@ -114,6 +115,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
boolean connectedAndNOTLoggedIn; boolean connectedAndNOTLoggedIn;
boolean disconnected; boolean disconnected;
boolean disconnectionPerformedByUser = false; boolean disconnectionPerformedByUser = false;
private MessageHistoryRecorder messageHistoryRecorder =
MessageHistoryRecorder.disabled();
private final On4KstConnectionManager on4KstConnectionManager = private final On4KstConnectionManager on4KstConnectionManager =
new On4KstConnectionManager(this); new On4KstConnectionManager(this);
@@ -2454,6 +2457,24 @@ private ObservableList<String>
* reachability worker threads. Therefore the listener is always invoked on the * reachability worker threads. Therefore the listener is always invoked on the
* JavaFX application thread.</p> * JavaFX application thread.</p>
* *
* 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 * @param reason short debug reason for the UI log
*/ */
public void fireUserListUpdate(String reason) { public void fireUserListUpdate(String reason) {
@@ -2870,6 +2891,13 @@ private ObservableList<String>
chatPreferences = new ChatPreferences(); chatPreferences = new ChatPreferences();
chatPreferences.readPreferencesFromXmlFile(); 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; // this.statusListener = listener;
lstNotify_QSOSniffer_sniffedCallSignList = lstNotify_QSOSniffer_sniffedCallSignList =
chatPreferences chatPreferences
@@ -3108,6 +3136,15 @@ private ObservableList<String>
return chatPreferences; 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) { public void setChatPreferences(ChatPreferences chatPreferences) {
this.chatPreferences = chatPreferences; this.chatPreferences = chatPreferences;
} }
@@ -11,6 +11,8 @@ import java.util.Hashtable;
import java.util.Locale; import java.util.Locale;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.logging.Level;
import java.util.logging.Logger;
import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.SimpleStringProperty;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
@@ -30,6 +32,8 @@ import java.util.function.LongPredicate;
* *
*/ */
public class MessageBusManagementThread extends Thread { public class MessageBusManagementThread extends Thread {
private static final Logger LOGGER =
Logger.getLogger(MessageBusManagementThread.class.getName());
int index; int index;
@@ -255,7 +259,7 @@ public class MessageBusManagementThread extends Thread {
stringAggregation += s + " "; 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: " // System.out.println("Processed QRG Entry [" + this.client.getChatMemberTable().size() + "]: Call: "
// + member.getCallSign() + ", QRA: " + member.getQra() + ", Name: " + member.getName()); // + member.getCallSign() + ", QRA: " + member.getQra() + ", Name: " + member.getName());
@@ -442,7 +446,7 @@ public class MessageBusManagementThread extends Thread {
finalDetectedFrequency finalDetectedFrequency
); );
System.out.println( LOGGER.log(Level.FINE,
"[SmartParser] Detected for " "[SmartParser] Detected for "
+ sender.getCallSign() + sender.getCallSign()
+ ": " + ": "
@@ -578,11 +582,11 @@ public class MessageBusManagementThread extends Thread {
if (lookForThis == null) { if (lookForThis == null) {
System.out.println( LOGGER.warning(
"[ChecklistForChatMemberIndexByCallsign] ERROR: null Value for Chatmember detected! Member cannot be in the list!"); "[ChecklistForChatMemberIndexByCallsign] ERROR: null Value for Chatmember detected! Member cannot be in the list!");
return -1; return -1;
} else if (lookForThis.getCallSign() == null) { } else if (lookForThis.getCallSign() == null) {
System.out.println( LOGGER.warning(
"[ChecklistForChatMemberIndexByCallsign] ERROR: null Value in Callsign detected! Member cannot be in the list!"); "[ChecklistForChatMemberIndexByCallsign] ERROR: null Value in Callsign detected! Member cannot be in the list!");
return -1; return -1;
} }
@@ -613,7 +617,11 @@ public class MessageBusManagementThread extends Thread {
if (list.get(i).getCallSign().equals(lookForThis.getCallSign())) { if (list.get(i).getCallSign().equals(lookForThis.getCallSign())) {
//TODO: New since 1.26! Check against category! //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 // System.out
// .println("MSGBUSHELBER: Found " + chatMember.getCallSign() + " at " + list.indexOf(chatMember)); // .println("MSGBUSHELBER: Found " + chatMember.getCallSign() + " at " + list.indexOf(chatMember));
@@ -623,12 +631,14 @@ public class MessageBusManagementThread extends Thread {
return list.indexOf(list.get(i)); return list.indexOf(list.get(i));
} //new 1.26 } //new 1.26
else { 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 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 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 // return -1; //if category dont match, return: member not found
@@ -1010,11 +1020,11 @@ public class MessageBusManagementThread extends Thread {
newMember.setCallSign(splittedMessageLine[2]); 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()); + this.client.getActiveChatMemberCount() + "] :" + newMember.getCallSign());
if (!this.client.removeActiveChatMember(newMember)) { 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()); + 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() } else if (newMessageArrived.getSender().getCallSign().toUpperCase()
.equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) { .equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) {
@@ -1320,24 +1331,27 @@ public class MessageBusManagementThread extends Thread {
} }
} catch (Exception exception) { } 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(); // 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())) + 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()))); " / sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(client.getChatPreferences().getStn_loginLocatorMainCat())));
} else { } 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())) + 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()))); " ; sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(client.getChatPreferences().getStn_loginLocatorMainCat())));
newMessageArrived.getSender().setInAngleAndRange(false); newMessageArrived.getSender().setInAngleAndRange(false);
} }
} else { } else {
newMessageArrived.getSender().setInAngleAndRange(false); 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.getSender().getCallSign() + " -> "
+ newMessageArrived.getReceiver().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()); // System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
} }
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) { } catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
System.out.println("MSGBS bgfx, <<<catched error>>>: referenced user left the chat during messageprocessing or message got before user entered chat message: " LOGGER.log(Level.WARNING,
+ referenceDeletedByUserLeftChatDuringMessageprocessing.getMessage()); "Referenced user disappeared while processing a message",
referenceDeletedByUserLeftChatDuringMessageprocessing.printStackTrace(); referenceDeletedByUserLeftChatDuringMessageprocessing);
} }
// sdtout to me message-List // sdtout to me message-List
@@ -1364,12 +1378,13 @@ public class MessageBusManagementThread extends Thread {
try { try {
System.out.println("[MSGBUSMGT:] processed message: " + newMessageArrived.getChatCategory().getCategoryNumber() LOGGER.fine("Processed message: " + newMessageArrived.getChatCategory().getCategoryNumber()
+ " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> " + " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> "
+ newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText()); + newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText());
} catch (Exception exceptionOccured) { } catch (Exception exceptionOccured) {
System.out.println("[MSGMgtBus: ERROR CHATCHED ON MAYBE NULL ISSUE]: " + exceptionOccured.getMessage()); LOGGER.log(Level.WARNING,
exceptionOccured.printStackTrace(); "Could not format processed-message diagnostic",
exceptionOccured);
} }
// TODO: Next: get frequency infos out of name? // TODO: Next: get frequency infos out of name?
@@ -1401,11 +1416,11 @@ public class MessageBusManagementThread extends Thread {
} }
if (foundThisInChatMemberList != null) { 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])); + foundThisInChatMemberList.getQra() + " new is: " + splittedMessageLine[3]));
this.client.updateActiveChatMemberLocator(temp4, splittedMessageLine[3]); this.client.updateActiveChatMemberLocator(temp4, splittedMessageLine[3]);
} else { } 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!")); + (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])); stateChangeMember.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1]));
if (!this.client.updateActiveChatMemberState(stateChangeMember, stateChangeMember.getState())) { 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()); + stateChangeMember.getCallSign() + " / " + stateChangeMember.getChatCategory());
} }
@@ -1552,7 +1567,7 @@ public class MessageBusManagementThread extends Thread {
if (splittedMessageLine[0].contains(USERINFOUPDATEORUSERISBACK)) { if (splittedMessageLine[0].contains(USERINFOUPDATEORUSERISBACK)) {
if (splittedMessageLine.length < 6) { if (splittedMessageLine.length < 6) {
System.out.println("[MSGBUSMGT, warning:] Malformed UM3 message ignored: " LOGGER.warning("Malformed UM3 message ignored: "
+ messageToProcess.getMessageText()); + messageToProcess.getMessageText());
} else { } else {
ChatMember stateChangeMember = new ChatMember(); ChatMember stateChangeMember = new ChatMember();
@@ -1584,7 +1599,7 @@ public class MessageBusManagementThread extends Thread {
if (updatedActiveMember) { if (updatedActiveMember) {
this.client.getDbHandler().storeChatMember(stateChangeMember); // TODO: not clean, it should be an update this.client.getDbHandler().storeChatMember(stateChangeMember); // TODO: not clean, it should be an update
} else { } else {
System.out.println("[MSGBUSMGT, Info:] UM3 ignored for inactive user: " LOGGER.info("UM3 ignored for inactive user: "
+ stateChangeMember.getCallSign() + " / " + stateChangeMember.getChatCategory()); + stateChangeMember.getCallSign() + " / " + stateChangeMember.getChatCategory());
} }
} }
@@ -1658,7 +1673,8 @@ public class MessageBusManagementThread extends Thread {
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) 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() } else if (newMessageArrived.getSender().getCallSign().toUpperCase()
.equals(this.client.getChatPreferences().getStn_loginCallSign().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()); // System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
} }
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) { } catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
System.out.println("MSGBS bgfx, <<<catched error>>>: referenced user left the chat during messageprocessing or message got before user entered chat message: " LOGGER.log(Level.WARNING,
+ referenceDeletedByUserLeftChatDuringMessageprocessing.getMessage()); "Referenced user disappeared while processing a historic message",
referenceDeletedByUserLeftChatDuringMessageprocessing.printStackTrace(); referenceDeletedByUserLeftChatDuringMessageprocessing);
} }
// sdtout to me message-List // sdtout to me message-List
@@ -1721,12 +1737,13 @@ public class MessageBusManagementThread extends Thread {
try { try {
System.out.println("[MSGBUSMGT:] processed message: " + newMessageArrived.getChatCategory().getCategoryNumber() LOGGER.fine("Processed historic message: " + newMessageArrived.getChatCategory().getCategoryNumber()
+ " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> " + " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> "
+ newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText()); + newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText());
} catch (Exception exceptionOccured) { } catch (Exception exceptionOccured) {
System.out.println("[MSGMgtBus: ERROR CHATCHED ON MAYBE NULL ISSUE]: " + exceptionOccured.getMessage()); LOGGER.log(Level.WARNING,
exceptionOccured.printStackTrace(); "Could not format historic-message diagnostic",
exceptionOccured);
} }
// --- Band/QRG recognition (fills ChatMember.knownActiveBands) --- // --- Band/QRG recognition (fills ChatMember.knownActiveBands) ---
@@ -1742,8 +1759,8 @@ public class MessageBusManagementThread extends Thread {
*/ */
if (SRVR_USERLISTEND.equals(opcode)) { if (SRVR_USERLISTEND.equals(opcode)) {
if (splittedMessageLine.length < 2) { if (splittedMessageLine.length < 2) {
System.out.println( LOGGER.warning(
"[MSGBUSMGT, Warning:] Ignoring malformed UE frame: " "Ignoring malformed UE frame: "
+ messageToProcess.getMessageText()); + messageToProcess.getMessageText());
return; return;
} }
@@ -1767,7 +1784,7 @@ public class MessageBusManagementThread extends Thread {
//-> LOGSTAT|114|Wrong password!| //-> LOGSTAT|114|Wrong password!|
if (splittedMessageLine[0].contains(SRVR_LOGSTAT) && splittedMessageLine.length <= 5) { 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")) { if (splittedMessageLine[2].contains("password")) {
splittedMessageLine[2] += " pse disc- and reconnect"; splittedMessageLine[2] += " pse disc- and reconnect";
@@ -1815,9 +1832,8 @@ public class MessageBusManagementThread extends Thread {
// + messageToProcess.getMessageText() + "\n"); // + messageToProcess.getMessageText() + "\n");
// bufwrtrDBGMSGOut.flush(); // bufwrtrDBGMSGOut.flush();
System.out.print(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() LOGGER.warning("Detected unhandled ON4KST frame: "
+ " [MSGBUSMGT:] Critical, detected unhandled Chatmessage -> " + messageToProcess.getMessageText());
+ messageToProcess.getMessageText() + "\n");
} }
@@ -1915,7 +1931,7 @@ public class MessageBusManagementThread extends Thread {
} else if (categoryNumber.equals(this.client.getChatCategorySecondChat().getCategoryNumber() + "")) { } else if (categoryNumber.equals(this.client.getChatCategorySecondChat().getCategoryNumber() + "")) {
return this.client.getChatCategorySecondChat(); return this.client.getChatCategorySecondChat();
} else { } 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 return this.client.getChatCategoryMain(); //Chatcategory default decision
} }
@@ -1955,7 +1971,7 @@ public class MessageBusManagementThread extends Thread {
|| incoming.getSender().getCallSign() == null || incoming.getSender().getCallSign() == null
|| incoming.getSender().getCallSign().isBlank()) { || incoming.getSender().getCallSign().isBlank()) {
System.err.println( LOGGER.warning(
"KST4Contest auto-answer skipped: " "KST4Contest auto-answer skipped: "
+ "incoming message has no valid sender callsign." + "incoming message has no valid sender callsign."
); );
@@ -1970,7 +1986,7 @@ public class MessageBusManagementThread extends Thread {
); );
if (qrg == null || qrg.isBlank()) { if (qrg == null || qrg.isBlank()) {
System.err.println( LOGGER.warning(
"KST4Contest QRG auto-answer skipped for " "KST4Contest QRG auto-answer skipped for "
+ incoming.getSender().getCallSign() + incoming.getSender().getCallSign()
+ ": no QRG is available for chat category " + ": no QRG is available for chat category "
@@ -1987,7 +2003,7 @@ public class MessageBusManagementThread extends Thread {
.getMessageHandling_autoAnswerTextMainCat(); .getMessageHandling_autoAnswerTextMainCat();
if (payload == null || payload.isBlank()) { if (payload == null || payload.isBlank()) {
System.err.println( LOGGER.warning(
"KST4Contest generic auto-answer skipped for " "KST4Contest generic auto-answer skipped for "
+ incoming.getSender().getCallSign() + incoming.getSender().getCallSign()
+ ": the configured answer text is empty." + ": the configured answer text is empty."
@@ -2013,7 +2029,7 @@ public class MessageBusManagementThread extends Thread {
return On4KstProtocol.messageText(messageText); return On4KstProtocol.messageText(messageText);
} catch (IllegalArgumentException invalidMessage) { } catch (IllegalArgumentException invalidMessage) {
System.err.println( LOGGER.warning(
"KST4Contest auto-answer skipped for " "KST4Contest auto-answer skipped for "
+ incoming.getSender().getCallSign() + incoming.getSender().getCallSign()
+ ": " + ": "
@@ -2166,7 +2182,7 @@ public class MessageBusManagementThread extends Thread {
// } // }
if (messageTextRaw.getMessageText() == null) { if (messageTextRaw.getMessageText() == null) {
System.out.println("[MSGBUSMGT:] ERROR! got NULL message! BYE!"); LOGGER.severe("Received ON4KST message with null payload");
// this.interrupt(); // this.interrupt();
// break; // break;
} }
@@ -2184,35 +2200,38 @@ public class MessageBusManagementThread extends Thread {
if (!On4KstProtocol.isInternalDxqResponse( if (!On4KstProtocol.isInternalDxqResponse(
messageTextRaw.getMessageText())) { messageTextRaw.getMessageText())) {
System.out.println(messageTextRaw.getMessageText() + " <- RXed"); // Stdout at LOGGER.log(Level.FINE, "ON4KST RX: {0}",
// Console#######################################################TODO:Wichtig messageTextRaw.getMessageText());
} }
try { try {
processRXMessage23001(messageTextRaw); processRXMessage23001(messageTextRaw);
} catch (IOException e) { } catch (IOException e) {
System.out.println("MsgBusMgt: process23001 went wrong / IO Error"); LOGGER.log(Level.WARNING,
e.printStackTrace(); "I/O error while processing ON4KST frame", e);
} catch (SQLException e) { } catch (SQLException e) {
System.out.println("MsgBusMgt: process23001 went wrong / SQL Error"); LOGGER.log(Level.WARNING,
e.printStackTrace(); "SQL error while processing ON4KST frame", e);
} catch (RuntimeException e) { } catch (RuntimeException e) {
System.out.println("MsgBusMgt: process23001 went wrong / Runtime Error while processing: " LOGGER.log(Level.WARNING,
+ messageTextRaw.getMessageText()); "Runtime error while processing ON4KST opcode "
e.printStackTrace(); + On4KstProtocol.opcode(
messageTextRaw.getMessageText()),
e);
} }
} }
} catch (InterruptedException e1) { } catch (InterruptedException e1) {
this.interrupt(); 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! break;// TODO Change at may24, avoid uncloadability. Check if this could lead to further errors on instable link!
// client.getMessageRXBus().clear(); // client.getMessageRXBus().clear();
} }
} // while true end } // while true end
System.out.println("Msgbusmgt: interrupt"); LOGGER.fine("ON4KST message processor stopped");
this.interrupt(); this.interrupt();
} }
} }
@@ -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.
*
* <p>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.</p>
*/
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();
}
}
}
@@ -265,16 +265,21 @@ final class On4KstConnectionManager {
receiveQueue, receiveQueue,
transmitQueue); transmitQueue);
MessageHistoryRecorder historyRecorder =
controller.getMessageHistoryRecorder();
ReadThread readThread = new ReadThread( ReadThread readThread = new ReadThread(
token, socket, receiveQueue, this::isActiveSession, token, socket, receiveQueue, this::isActiveSession,
line -> onInboundActivity(token, line), line -> onInboundActivity(token, line),
failure -> onConnectionFailure(token, failure)); failure -> onConnectionFailure(token, failure),
historyRecorder);
WriteThread writeThread = new WriteThread( WriteThread writeThread = new WriteThread(
token, socket, transmitQueue, token, socket, transmitQueue,
mainCategory, mainCategory,
this::isActiveSession, this::isActiveSession,
failure -> onConnectionFailure(token, failure), failure -> onConnectionFailure(token, failure),
controller::onOn4KstOutboundFrameRejected); controller::onOn4KstOutboundFrameRejected,
historyRecorder);
MessageBusManagementThread messageProcessor = MessageBusManagementThread messageProcessor =
new MessageBusManagementThread( new MessageBusManagementThread(
controller, controller, token, receiveQueue, controller, controller, token, receiveQueue,
@@ -42,6 +42,33 @@ final class On4KstProtocol {
+ "|25|0|1|" + Math.max(0L, lastMessageTimestamp) + "|0|"; + "|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) + "<REDACTED>";
}
int passwordSeparator = frame.indexOf('|', callsignSeparator + 1);
if (passwordSeparator < 0) {
return frame.substring(0, callsignSeparator + 1) + "<REDACTED>";
}
return frame.substring(0, callsignSeparator + 1)
+ "<REDACTED>"
+ frame.substring(passwordSeparator);
}
/** Builds the settings-complete frame for the supplied chat category. */ /** Builds the settings-complete frame for the supplied chat category. */
static String settingsDone(int category) { static String settingsDone(int category) {
return "SDONE|" + category(category) + "|"; return "SDONE|" + category(category) + "|";
@@ -30,6 +30,7 @@ public class ReadThread extends Thread {
private final LongPredicate sessionIsActive; private final LongPredicate sessionIsActive;
private final Consumer<String> inboundActivity; private final Consumer<String> inboundActivity;
private final Consumer<Throwable> connectionFailure; private final Consumer<Throwable> connectionFailure;
private final MessageHistoryRecorder historyRecorder;
private final BufferedReader reader; private final BufferedReader reader;
/** /**
@@ -41,7 +42,8 @@ public class ReadThread extends Thread {
@Deprecated @Deprecated
public ReadThread(Socket socket, ChatController client) throws IOException { public ReadThread(Socket socket, ChatController client) throws IOException {
this(0L, socket, client.getMessageRXBus(), ignored -> true, this(0L, socket, client.getMessageRXBus(), ignored -> true,
ignored -> { }, ignored -> { }); ignored -> { }, ignored -> { },
client.getMessageHistoryRecorder());
} }
/** /**
@@ -62,6 +64,21 @@ public class ReadThread extends Thread {
LongPredicate sessionIsActive, LongPredicate sessionIsActive,
Consumer<String> inboundActivity, Consumer<String> inboundActivity,
Consumer<Throwable> connectionFailure Consumer<Throwable> 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<ChatMessage> receiveQueue,
LongPredicate sessionIsActive,
Consumer<String> inboundActivity,
Consumer<Throwable> connectionFailure,
MessageHistoryRecorder historyRecorder
) throws IOException { ) throws IOException {
this.sessionId = sessionId; this.sessionId = sessionId;
this.socket = socket; this.socket = socket;
@@ -69,6 +86,8 @@ public class ReadThread extends Thread {
this.sessionIsActive = sessionIsActive; this.sessionIsActive = sessionIsActive;
this.inboundActivity = inboundActivity; this.inboundActivity = inboundActivity;
this.connectionFailure = connectionFailure; this.connectionFailure = connectionFailure;
this.historyRecorder = historyRecorder == null
? MessageHistoryRecorder.disabled() : historyRecorder;
this.reader = new BufferedReader(new InputStreamReader( this.reader = new BufferedReader(new InputStreamReader(
socket.getInputStream(), StandardCharsets.UTF_8)); socket.getInputStream(), StandardCharsets.UTF_8));
} }
@@ -86,6 +105,8 @@ public class ReadThread extends Thread {
throw new EOFException("ON4KST closed the TCP connection"); throw new EOFException("ON4KST closed the TCP connection");
} }
historyRecorder.recordRx(response);
if (!sessionIsActive.test(sessionId)) { if (!sessionIsActive.test(sessionId)) {
break; break;
} }
@@ -285,7 +285,7 @@ public final class ScoreService {
member.setCurrentPriorityScore(score == null ? 0.0 : score); member.setCurrentPriorityScore(score == null ? 0.0 : score);
} }
controller.fireUserListUpdate("Priority scores projected to ChatMember"); controller.fireUserListUpdate();
} }
private void updateSelectedScoreFromSnapshot(ScoreSnapshot snap) { private void updateSelectedScoreFromSnapshot(ScoreSnapshot snap) {
@@ -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 // new: userlist-update
/** /**
@@ -31,6 +31,7 @@ public class WriteThread extends Thread {
private final LongPredicate sessionIsActive; private final LongPredicate sessionIsActive;
private final Consumer<Throwable> connectionFailure; private final Consumer<Throwable> connectionFailure;
private final Consumer<String> rejectedFrame; private final Consumer<String> rejectedFrame;
private final MessageHistoryRecorder historyRecorder;
private final BufferedWriter writer; private final BufferedWriter writer;
private final int defaultCategory; private final int defaultCategory;
@@ -45,7 +46,9 @@ public class WriteThread extends Thread {
this(0L, socket, client.getMessageTXBus(), this(0L, socket, client.getMessageTXBus(),
client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber(), client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber(),
ignored -> true, 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, LongPredicate sessionIsActive,
Consumer<Throwable> connectionFailure, Consumer<Throwable> connectionFailure,
Consumer<String> rejectedFrame Consumer<String> 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<ChatMessage> transmitQueue,
int defaultCategory,
LongPredicate sessionIsActive,
Consumer<Throwable> connectionFailure,
Consumer<String> rejectedFrame,
MessageHistoryRecorder historyRecorder
) throws IOException { ) throws IOException {
this.sessionId = sessionId; this.sessionId = sessionId;
this.socket = socket; this.socket = socket;
@@ -76,6 +95,8 @@ public class WriteThread extends Thread {
this.sessionIsActive = sessionIsActive; this.sessionIsActive = sessionIsActive;
this.connectionFailure = connectionFailure; this.connectionFailure = connectionFailure;
this.rejectedFrame = rejectedFrame; this.rejectedFrame = rejectedFrame;
this.historyRecorder = historyRecorder == null
? MessageHistoryRecorder.disabled() : historyRecorder;
this.writer = new BufferedWriter(new OutputStreamWriter( this.writer = new BufferedWriter(new OutputStreamWriter(
socket.getOutputStream(), StandardCharsets.UTF_8)); socket.getOutputStream(), StandardCharsets.UTF_8));
} }
@@ -147,6 +168,7 @@ public class WriteThread extends Thread {
writer.write(frame); writer.write(frame);
writer.write("\r\n"); writer.write("\r\n");
writer.flush(); writer.flush();
historyRecorder.recordTx(frame);
} }
private boolean isPoisonPill(ChatMessage message) { private boolean isPoisonPill(ChatMessage message) {
@@ -55,7 +55,7 @@ public class ChatPreferences {
* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults. * Reading must stay backwards compatible: missing/unknown tags should fall back to defaults.
*/ */
// private static final int CONFIG_VERSION = 2; // 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. // Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility.
private static final String TAG_CONFIG_VERSION = "configVersion"; private static final String TAG_CONFIG_VERSION = "configVersion";
@@ -80,6 +80,19 @@ public class ChatPreferences {
*/ */
public ChatPreferences() { public ChatPreferences() {
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, PREFERENCE_RESOURCE, PREFERENCES_FILE); 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"); // lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB");
@@ -329,6 +342,7 @@ public class ChatPreferences {
boolean messageHandling_autoAnswerEnabledSecondCat = false; boolean messageHandling_autoAnswerEnabledSecondCat = false;
boolean messageHandling_autoAnswerToQRGRequestEnabled; boolean messageHandling_autoAnswerToQRGRequestEnabled;
private volatile boolean messageHandling_debugModeToFileEnabled = true;
/********************************************************************************* /*********************************************************************************
* *
@@ -379,6 +393,17 @@ public class ChatPreferences {
return messageHandling_autoAnswerEnabledSecondCat; 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) { public void setMessageHandling_autoAnswerEnabledSecondCat(boolean messageHandling_autoAnswerEnabledSecondCat) {
this.messageHandling_autoAnswerEnabledSecondCat = messageHandling_autoAnswerEnabledSecondCat; this.messageHandling_autoAnswerEnabledSecondCat = messageHandling_autoAnswerEnabledSecondCat;
} }
@@ -2015,6 +2040,12 @@ public class ChatPreferences {
autoAnswerToQrgRequestEnabled.setTextContent(isMessageHandling_autoAnswerToQRGRequestEnabled()+""); autoAnswerToQrgRequestEnabled.setTextContent(isMessageHandling_autoAnswerToQRGRequestEnabled()+"");
messageHandling.appendChild(autoAnswerToQrgRequestEnabled); messageHandling.appendChild(autoAnswerToQrgRequestEnabled);
Element debugModeToFileEnabled = doc.createElement(
"messageHandling_debugModeToFileEnabled");
debugModeToFileEnabled.setTextContent(
Boolean.toString(isMessageHandling_debugModeToFileEnabled()));
messageHandling.appendChild(debugModeToFileEnabled);
/**************************** /****************************
* GUI BEHAVIOUR * GUI BEHAVIOUR
***************************/ ***************************/
@@ -2921,6 +2952,7 @@ public class ChatPreferences {
* case messageHandling * case messageHandling
* *
***********************************************/ ***********************************************/
messageHandling_debugModeToFileEnabled = true;
Element messageHandlingEl = getFirstElement(doc, "messageHandling"); Element messageHandlingEl = getFirstElement(doc, "messageHandling");
if (messageHandlingEl != null) { if (messageHandlingEl != null) {
messageHandling_autoAnswerTextMainCat = getText( messageHandling_autoAnswerTextMainCat = getText(
@@ -2950,6 +2982,10 @@ public class ChatPreferences {
messageHandling_autoAnswerEnabledSecondCat, messageHandling_autoAnswerEnabledSecondCat,
"messageHandling_autoAnswerEnabledSecondCat", "messageHandling_autoAnswerEnabledSecondCat",
"autoAnswerEnabledSecondCat"); "autoAnswerEnabledSecondCat");
messageHandling_debugModeToFileEnabled = getBoolean(
messageHandlingEl,
messageHandling_debugModeToFileEnabled,
"messageHandling_debugModeToFileEnabled");
/* /*
* The user interface intentionally exposes one shared generic auto-answer * The user interface intentionally exposes one shared generic auto-answer
@@ -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();
}
}
}
@@ -8,10 +8,8 @@ import java.io.IOException;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.*; import java.util.*;
import java.util.logging.FileHandler;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Predicate; import java.util.function.Predicate;
@@ -68,6 +66,7 @@ import javafx.scene.shape.Polygon;
import javafx.stage.Screen; import javafx.stage.Screen;
import kst4contest.logic.BandOpportunityResolver; import kst4contest.logic.BandOpportunityResolver;
import kst4contest.utils.ApplicationFileLogging;
import kst4contest.utils.ApplicationFileUtils; import kst4contest.utils.ApplicationFileUtils;
import kst4contest.view.map.StationMapBridge; import kst4contest.view.map.StationMapBridge;
import kst4contest.view.map.StationMapView; import kst4contest.view.map.StationMapView;
@@ -84,6 +83,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
private static final Logger LOGGER = Logger.getLogger( private static final Logger LOGGER = Logger.getLogger(
Kst4ContestApplication.class.getName()); Kst4ContestApplication.class.getName());
private static ApplicationFileLogging fileLogging;
private static final String SIMPLE_LOG_MANUAL_URL = private static final String SIMPLE_LOG_MANUAL_URL =
"https://kst4contest.hamradioonline.de/manual/en/log-sync/" "https://kst4contest.hamradioonline.de/manual/en/log-sync/"
+ "#method-1-universal-file-based-callsign-interpreter-simplelogfile"; + "#method-1-universal-file-based-callsign-interpreter-simplelogfile";
@@ -6229,7 +6229,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override @Override
public void stop() { 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) { if (layoutAutosave != null) {
layoutAutosave.flushPending(); layoutAutosave.flushPending();
} }
@@ -6245,8 +6245,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
try { try {
chatcontroller.disconnect("CLOSEALL"); chatcontroller.disconnect("CLOSEALL");
} catch (Exception e) { } 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(); // Platform.exit();
System.exit(0); System.exit(0);
@@ -6683,6 +6685,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ChatMember ownChatMemberObject = new ChatMember(); ChatMember ownChatMemberObject = new ChatMember();
chatcontroller = new ChatController(ownChatMemberObject, this); // instantiate the Chatcontroller with the user object chatcontroller = new ChatController(ownChatMemberObject, this); // instantiate the Chatcontroller with the user object
setDebugFileLoggingEnabled(chatcontroller.getChatPreferences()
.isMessageHandling_debugModeToFileEnabled());
layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences()); layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences());
messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences()); messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences());
chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual 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(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(); VBox vbxMsgHandlBeacon = new VBox();
vbxMsgHandlBeacon.setPadding(new Insets(10, 10, 10, 10)); vbxMsgHandlBeacon.setPadding(new Insets(10, 10, 10, 10));
vbxMsgHandlBeacon.getChildren().addAll(grdPnlMessageHandlingBeacon); vbxMsgHandlBeacon.getChildren().addAll(grdPnlMessageHandlingBeacon);
@@ -12218,20 +12247,33 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} }
private static void setupFileLogging() { private static void setupFileLogging() {
if (fileLogging != null) {
return;
}
try { try {
String logDir = Path.of(System.getProperty("user.home"), ".praktiKST").toString(); Path logFile = Path.of(ApplicationFileUtils.getFilePath(
new File(logDir).mkdirs(); ApplicationConstants.APPLICATION_NAME,
FileHandler fileHandler = new FileHandler(logDir + "/kst4contest-errors.log", true); "kst4contest-errors.log"));
// Connection loss/reconnect diagnostics are warnings, not fatal crashes. // Start conservatively until the persisted preference has been loaded.
fileHandler.setLevel(Level.WARNING); fileLogging = new ApplicationFileLogging(logFile, false);
fileHandler.setFormatter(new SimpleFormatter());
Logger rootLogger = Logger.getLogger("");
rootLogger.addHandler(fileHandler);
} catch (IOException e) { } catch (IOException e) {
System.err.println("Could not set up file logging: " + e.getMessage()); 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 @Override
public void onThreadStatusChanged( public void onThreadStatusChanged(
String key, String key,
@@ -12351,13 +12393,27 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} }
@Override @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(() -> { Platform.runLater(() -> {
pendingUserListUpdateReason = reason; if (diagnosticReason != null && !diagnosticReason.isBlank()) {
pendingUserListUpdateReason = diagnosticReason;
}
if (userListRefreshCoalescer == null) { if (userListRefreshCoalescer == null) {
userListRefreshCoalescer = new PauseTransition(Duration.millis(300)); userListRefreshCoalescer = new PauseTransition(Duration.millis(300));
userListRefreshCoalescer.setOnFinished(event -> { userListRefreshCoalescer.setOnFinished(event -> {
final String completedDiagnosticReason = pendingUserListUpdateReason;
pendingUserListUpdateReason = "";
forceChatMemberFilterRefresh(); forceChatMemberFilterRefresh();
if (tbl_chatMember != null) { if (tbl_chatMember != null) {
@@ -12366,7 +12422,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
refreshStationMapIfVisible(); refreshStationMapIfVisible();
System.out.println("KST4Capp, UI Update Trigger: " + pendingUserListUpdateReason); if (!completedDiagnosticReason.isEmpty()) {
System.out.println("KST4Capp, UI Update Trigger: "
+ completedDiagnosticReason);
}
}); });
} }
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<praktiKST> <praktiKST>
<configVersion>8</configVersion>
<station> <station>
<LoginCallSign>DO5AMF</LoginCallSign> <LoginCallSign>DO5AMF</LoginCallSign>
<LoginPassword>changeMe</LoginPassword> <LoginPassword>changeMe</LoginPassword>
@@ -128,6 +129,7 @@
<autoAnswerTextSecondCat>Hi, sry I am not qrv, just testing new features of KST4Contest 1.251</autoAnswerTextSecondCat> <autoAnswerTextSecondCat>Hi, sry I am not qrv, just testing new features of KST4Contest 1.251</autoAnswerTextSecondCat>
<autoAnswerEnabledSecondCat>false</autoAnswerEnabledSecondCat> <autoAnswerEnabledSecondCat>false</autoAnswerEnabledSecondCat>
<autoAnswerToQrgRequestEnabled>true</autoAnswerToQrgRequestEnabled> <autoAnswerToQrgRequestEnabled>true</autoAnswerToQrgRequestEnabled>
<messageHandling_debugModeToFileEnabled>true</messageHandling_debugModeToFileEnabled>
</messageHandling> </messageHandling>
<guiSaveableOptions> <guiSaveableOptions>
<guiOptions_defaultFilterNothing>false</guiOptions_defaultFilterNothing> <guiOptions_defaultFilterNothing>false</guiOptions_defaultFilterNothing>
@@ -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<String> lines = Files.readAllLines(historyFile());
assertTrue(lines.get(0).endsWith(
"\tTX\tLOGINC|DL1ABC|<REDACTED>|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<String> lines = Files.readAllLines(historyFile());
assertEquals(eventCount, lines.size());
Set<Integer> 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<Path> 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");
}
}
@@ -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<Void> 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<ChatMessage> 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<String> 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<ChatMessage> 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<ChatMessage> rejectedQueue =
new LinkedBlockingQueue<>();
AtomicBoolean rejectedActive = new AtomicBoolean(true);
CompletableFuture<String> 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<ChatMessage> failedQueue =
new LinkedBlockingQueue<>();
CompletableFuture<Throwable> 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");
}
}
};
}
}
}
@@ -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.<String, ChatCategory>of(),
List.of());
applySnapshot(service, snapshot);
assertEquals(42.5, member.getCurrentPriorityScore());
List<Invocation> 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> 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);
}
}
@@ -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, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>7</configVersion>
<messageHandling>
<autoAnswerEnabled>false</autoAnswerEnabled>
</messageHandling>
</praktiKST>
""");
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("<configVersion>8</configVersion>"));
assertTrue(writtenXml.contains(
"<messageHandling_debugModeToFileEnabled>false"
+ "</messageHandling_debugModeToFileEnabled>"));
ChatPreferences restored = preferencesAt(preferencesFile);
assertTrue(restored.readPreferencesFromXmlFile());
assertFalse(restored.isMessageHandling_debugModeToFileEnabled());
}
private ChatPreferences preferencesAt(Path preferencesFile) {
return new ChatPreferences(preferencesFile);
}
}
@@ -113,7 +113,7 @@ class ChatPreferencesLayoutPersistenceTest {
assertFalse(writtenXml.contains("UNSAVED-CALL")); assertFalse(writtenXml.contains("UNSAVED-CALL"));
assertTrue(writtenXml.contains("<futureExtension mode=\"keep-me\">")); assertTrue(writtenXml.contains("<futureExtension mode=\"keep-me\">"));
assertTrue(writtenXml.contains("<futureLayoutValue>untouched</futureLayoutValue>")); assertTrue(writtenXml.contains("<futureLayoutValue>untouched</futureLayoutValue>"));
assertTrue(writtenXml.contains("<configVersion>7</configVersion>")); assertTrue(writtenXml.contains("<configVersion>8</configVersion>"));
assertTrue(writtenXml.contains("<GUIscn_ChatwindowMainSceneSizeHW>812.0;1340.0")); assertTrue(writtenXml.contains("<GUIscn_ChatwindowMainSceneSizeHW>812.0;1340.0"));
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false" assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
+ "</GUIstationMapClusteringEnabled>")); + "</GUIstationMapClusteringEnabled>"));
@@ -127,8 +127,6 @@ class ChatPreferencesLayoutPersistenceTest {
} }
private ChatPreferences preferencesAt(Path preferencesFile) { private ChatPreferences preferencesAt(Path preferencesFile) {
ChatPreferences preferences = new ChatPreferences(); return new ChatPreferences(preferencesFile);
preferences.setStoreAndRestorePreferencesFileName(preferencesFile.toString());
return preferences;
} }
} }
@@ -18,7 +18,9 @@ class ChatPreferencesStationMapClusteringTest {
@Test @Test
void clusteringIsEnabledByDefault() { void clusteringIsEnabledByDefault() {
assertTrue(new ChatPreferences().isGUIstationMapClusteringEnabled()); assertTrue(new ChatPreferences(
temporaryDirectory.resolve("unused-default.xml"))
.isGUIstationMapClusteringEnabled());
} }
@Test @Test
@@ -30,7 +32,7 @@ class ChatPreferencesStationMapClusteringTest {
assertTrue(written.writePreferencesToXmlFile()); assertTrue(written.writePreferencesToXmlFile());
String writtenXml = Files.readString(preferencesFile); String writtenXml = Files.readString(preferencesFile);
assertTrue(writtenXml.contains("<configVersion>7</configVersion>")); assertTrue(writtenXml.contains("<configVersion>8</configVersion>"));
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false" assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
+ "</GUIstationMapClusteringEnabled>")); + "</GUIstationMapClusteringEnabled>"));
@@ -80,7 +82,7 @@ class ChatPreferencesStationMapClusteringTest {
Files.writeString(preferencesFile, """ Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<praktiKST> <praktiKST>
<configVersion>7</configVersion> <configVersion>8</configVersion>
<guiOptions> <guiOptions>
<GUIstationMapClusteringEnabled>sometimes</GUIstationMapClusteringEnabled> <GUIstationMapClusteringEnabled>sometimes</GUIstationMapClusteringEnabled>
</guiOptions> </guiOptions>
@@ -94,8 +96,6 @@ class ChatPreferencesStationMapClusteringTest {
} }
private ChatPreferences preferencesAt(Path preferencesFile) { private ChatPreferences preferencesAt(Path preferencesFile) {
ChatPreferences preferences = new ChatPreferences(); return new ChatPreferences(preferencesFile);
preferences.setStoreAndRestorePreferencesFileName(preferencesFile.toString());
return preferences;
} }
} }
@@ -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"));
}
}