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
+1330 -91

No files matched your search

@@ -25,6 +25,7 @@ import kst4contest.logic.BandOpportunityResolver;
import kst4contest.logic.PriorityCalculator;
import kst4contest.model.*;
import kst4contest.test.MockKstServer;
import kst4contest.utils.ApplicationFileUtils;
import kst4contest.utils.PlayAudioUtils;
import kst4contest.locatorUtils.Location;
import kst4contest.view.Kst4ContestApplication;
@@ -114,6 +115,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
boolean connectedAndNOTLoggedIn;
boolean disconnected;
boolean disconnectionPerformedByUser = false;
private MessageHistoryRecorder messageHistoryRecorder =
MessageHistoryRecorder.disabled();
private final On4KstConnectionManager on4KstConnectionManager =
new On4KstConnectionManager(this);
@@ -2454,6 +2457,24 @@ private ObservableList<String>
* reachability worker threads. Therefore the listener is always invoked on the
* 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
*/
public void fireUserListUpdate(String reason) {
@@ -2870,6 +2891,13 @@ private ObservableList<String>
chatPreferences = new ChatPreferences();
chatPreferences.readPreferencesFromXmlFile();
Path historyFile = Path.of(ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME,
MessageHistoryRecorder.CURRENT_FILE_NAME));
messageHistoryRecorder = new MessageHistoryRecorder(
historyFile.getParent(),
chatPreferences.isMessageHandling_debugModeToFileEnabled(),
java.time.Clock.systemUTC());
// this.statusListener = listener;
lstNotify_QSOSniffer_sniffedCallSignList =
chatPreferences
@@ -3108,6 +3136,15 @@ private ObservableList<String>
return chatPreferences;
}
public MessageHistoryRecorder getMessageHistoryRecorder() {
return messageHistoryRecorder;
}
/** Applies the runtime debug preference to the raw session recorder. */
public void setMessageHistoryRecordingEnabled(boolean enabled) {
messageHistoryRecorder.setEnabled(enabled);
}
public void setChatPreferences(ChatPreferences chatPreferences) {
this.chatPreferences = chatPreferences;
}
@@ -11,6 +11,8 @@ import java.util.Hashtable;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.logging.Level;
import java.util.logging.Logger;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.ObservableList;
@@ -30,6 +32,8 @@ import java.util.function.LongPredicate;
*
*/
public class MessageBusManagementThread extends Thread {
private static final Logger LOGGER =
Logger.getLogger(MessageBusManagementThread.class.getName());
int index;
@@ -255,7 +259,7 @@ public class MessageBusManagementThread extends Thread {
stringAggregation += s + " ";
}
System.out.println("[MSGBUSMGT:] Processed qrg info: " + stringAggregation);
LOGGER.log(Level.FINE, "Processed QRG info: {0}", stringAggregation);
// System.out.println("Processed QRG Entry [" + this.client.getChatMemberTable().size() + "]: Call: "
// + member.getCallSign() + ", QRA: " + member.getQra() + ", Name: " + member.getName());
@@ -442,7 +446,7 @@ public class MessageBusManagementThread extends Thread {
finalDetectedFrequency
);
System.out.println(
LOGGER.log(Level.FINE,
"[SmartParser] Detected for "
+ sender.getCallSign()
+ ": "
@@ -578,11 +582,11 @@ public class MessageBusManagementThread extends Thread {
if (lookForThis == null) {
System.out.println(
LOGGER.warning(
"[ChecklistForChatMemberIndexByCallsign] ERROR: null Value for Chatmember detected! Member cannot be in the list!");
return -1;
} else if (lookForThis.getCallSign() == null) {
System.out.println(
LOGGER.warning(
"[ChecklistForChatMemberIndexByCallsign] ERROR: null Value in Callsign detected! Member cannot be in the list!");
return -1;
}
@@ -613,7 +617,11 @@ public class MessageBusManagementThread extends Thread {
if (list.get(i).getCallSign().equals(lookForThis.getCallSign())) {
//TODO: New since 1.26! Check against category!
System.out.println("MSGBUSMGT, DEBUG: Checking Chatcategories of found list member " + list.get(i).getCallSign() + " / " + list.get(i).getChatCategory() + " against " + lookForThis.getCallSign() + " / " + lookForThis.getChatCategory());
LOGGER.fine("Checking chat categories for list member "
+ list.get(i).getCallSign() + " / "
+ list.get(i).getChatCategory() + " against "
+ lookForThis.getCallSign() + " / "
+ lookForThis.getChatCategory());
// System.out
// .println("MSGBUSHELBER: Found " + chatMember.getCallSign() + " at " + list.indexOf(chatMember));
@@ -623,12 +631,14 @@ public class MessageBusManagementThread extends Thread {
return list.indexOf(list.get(i));
} //new 1.26
else {
System.out.println("MSGBUSMGT, DEBUG: Category does not match");
LOGGER.fine("Chat-member category does not match");
}
// System.out.println("--------------------------- chatcategory of list.get(i) = " + list.get(i).getChatCategory().getCategoryNumber());
System.out.println("--------------------------- chatcategory of lookforthisChatMember = " + lookForThis.getChatCategory().getCategoryNumber() );
LOGGER.log(Level.FINE,
"Requested chat-member category is {0}",
lookForThis.getChatCategory().getCategoryNumber());
}
// return list.indexOf(list.get(i)); //if no category found, return entry //TODO: ERROR detected here! Should work now, needs some proof
// return -1; //if category dont match, return: member not found
@@ -1010,11 +1020,11 @@ public class MessageBusManagementThread extends Thread {
newMember.setCallSign(splittedMessageLine[2]);
System.out.println("[MSGBUSMGT, Info:] User left Chat and will be removed from list ["
LOGGER.info("User left chat and will be removed from list ["
+ this.client.getActiveChatMemberCount() + "] :" + newMember.getCallSign());
if (!this.client.removeActiveChatMember(newMember)) {
System.out.println("[MSGBUSMGT, Info:] User sent left chat but was not active: "
LOGGER.info("User sent left-chat frame but was not active: "
+ newMember.getCallSign() + " / " + newMember.getChatCategory());
}
@@ -1242,7 +1252,8 @@ public class MessageBusManagementThread extends Thread {
}
System.out.println("message directed to me: " + newMessageArrived.getReceiver().getCallSign() + ".");
LOGGER.fine("Message directed to local callsign: "
+ newMessageArrived.getReceiver().getCallSign());
} else if (newMessageArrived.getSender().getCallSign().toUpperCase()
.equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) {
@@ -1320,24 +1331,27 @@ public class MessageBusManagementThread extends Thread {
}
} catch (Exception exception) {
System.out.println("[MSGBUSMGT, ERROR:] DXCluster messageserver error while processing spot for 0: " + newMessageArrived.getSender().getCallSign() + " // " + exception.getMessage());
LOGGER.log(Level.WARNING,
"DXCluster message-server error while processing spot for "
+ newMessageArrived.getSender().getCallSign(),
exception);
// exception.printStackTrace();
}
}
System.out.println(">>>>>>>>>> Anglewarning <<<<<<<<<< " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " +
LOGGER.fine("Angle warning " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " +
new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(newMessageArrived.getReceiver().getQra())) +
" / sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(client.getChatPreferences().getStn_loginLocatorMainCat())));
} else {
System.out.println("-notinangle- " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " +
LOGGER.fine("Not in angle " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " +
new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(newMessageArrived.getReceiver().getQra())) +
" ; sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(client.getChatPreferences().getStn_loginLocatorMainCat())));
newMessageArrived.getSender().setInAngleAndRange(false);
}
} else {
newMessageArrived.getSender().setInAngleAndRange(false);
System.out.println("[MSGBUSMGT, Info:] Skipping angle/range analysis for message with missing locator: "
LOGGER.info("Skipping angle/range analysis for message with missing locator: "
+ newMessageArrived.getSender().getCallSign() + " -> "
+ newMessageArrived.getReceiver().getCallSign());
}
@@ -1347,9 +1361,9 @@ public class MessageBusManagementThread extends Thread {
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
}
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
System.out.println("MSGBS bgfx, <<<catched error>>>: referenced user left the chat during messageprocessing or message got before user entered chat message: "
+ referenceDeletedByUserLeftChatDuringMessageprocessing.getMessage());
referenceDeletedByUserLeftChatDuringMessageprocessing.printStackTrace();
LOGGER.log(Level.WARNING,
"Referenced user disappeared while processing a message",
referenceDeletedByUserLeftChatDuringMessageprocessing);
}
// sdtout to me message-List
@@ -1364,12 +1378,13 @@ public class MessageBusManagementThread extends Thread {
try {
System.out.println("[MSGBUSMGT:] processed message: " + newMessageArrived.getChatCategory().getCategoryNumber()
LOGGER.fine("Processed message: " + newMessageArrived.getChatCategory().getCategoryNumber()
+ " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> "
+ newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText());
} catch (Exception exceptionOccured) {
System.out.println("[MSGMgtBus: ERROR CHATCHED ON MAYBE NULL ISSUE]: " + exceptionOccured.getMessage());
exceptionOccured.printStackTrace();
LOGGER.log(Level.WARNING,
"Could not format processed-message diagnostic",
exceptionOccured);
}
// TODO: Next: get frequency infos out of name?
@@ -1401,11 +1416,11 @@ public class MessageBusManagementThread extends Thread {
}
if (foundThisInChatMemberList != null) {
System.out.println("[MSGBUSMGT:] Locator Change of [" + (splittedMessageLine[2] + "], old was: "
LOGGER.info("Locator change of [" + (splittedMessageLine[2] + "], old was: "
+ foundThisInChatMemberList.getQra() + " new is: " + splittedMessageLine[3]));
this.client.updateActiveChatMemberLocator(temp4, splittedMessageLine[3]);
} else {
System.out.println("[MSGBUSMGT:] ERROR! Locator Change of ["
LOGGER.warning("Locator change of ["
+ (splittedMessageLine[2] + "] is not possible, user is not in the Table!"));
}
@@ -1537,7 +1552,7 @@ public class MessageBusManagementThread extends Thread {
stateChangeMember.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1]));
if (!this.client.updateActiveChatMemberState(stateChangeMember, stateChangeMember.getState())) {
System.out.println("[MSGBUSMGT, Info:] State change for inactive user: "
LOGGER.info("State change for inactive user: "
+ stateChangeMember.getCallSign() + " / " + stateChangeMember.getChatCategory());
}
@@ -1552,7 +1567,7 @@ public class MessageBusManagementThread extends Thread {
if (splittedMessageLine[0].contains(USERINFOUPDATEORUSERISBACK)) {
if (splittedMessageLine.length < 6) {
System.out.println("[MSGBUSMGT, warning:] Malformed UM3 message ignored: "
LOGGER.warning("Malformed UM3 message ignored: "
+ messageToProcess.getMessageText());
} else {
ChatMember stateChangeMember = new ChatMember();
@@ -1584,7 +1599,7 @@ public class MessageBusManagementThread extends Thread {
if (updatedActiveMember) {
this.client.getDbHandler().storeChatMember(stateChangeMember); // TODO: not clean, it should be an update
} else {
System.out.println("[MSGBUSMGT, Info:] UM3 ignored for inactive user: "
LOGGER.info("UM3 ignored for inactive user: "
+ stateChangeMember.getCallSign() + " / " + stateChangeMember.getChatCategory());
}
}
@@ -1658,7 +1673,8 @@ public class MessageBusManagementThread extends Thread {
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
System.out.println("Historic message directed to me: " + newMessageArrived.getReceiver().getCallSign() + ".");
LOGGER.fine("Historic message directed to local callsign: "
+ newMessageArrived.getReceiver().getCallSign());
} else if (newMessageArrived.getSender().getCallSign().toUpperCase()
.equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) {
@@ -1710,9 +1726,9 @@ public class MessageBusManagementThread extends Thread {
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
}
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
System.out.println("MSGBS bgfx, <<<catched error>>>: referenced user left the chat during messageprocessing or message got before user entered chat message: "
+ referenceDeletedByUserLeftChatDuringMessageprocessing.getMessage());
referenceDeletedByUserLeftChatDuringMessageprocessing.printStackTrace();
LOGGER.log(Level.WARNING,
"Referenced user disappeared while processing a historic message",
referenceDeletedByUserLeftChatDuringMessageprocessing);
}
// sdtout to me message-List
@@ -1721,12 +1737,13 @@ public class MessageBusManagementThread extends Thread {
try {
System.out.println("[MSGBUSMGT:] processed message: " + newMessageArrived.getChatCategory().getCategoryNumber()
LOGGER.fine("Processed historic message: " + newMessageArrived.getChatCategory().getCategoryNumber()
+ " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> "
+ newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText());
} catch (Exception exceptionOccured) {
System.out.println("[MSGMgtBus: ERROR CHATCHED ON MAYBE NULL ISSUE]: " + exceptionOccured.getMessage());
exceptionOccured.printStackTrace();
LOGGER.log(Level.WARNING,
"Could not format historic-message diagnostic",
exceptionOccured);
}
// --- Band/QRG recognition (fills ChatMember.knownActiveBands) ---
@@ -1742,8 +1759,8 @@ public class MessageBusManagementThread extends Thread {
*/
if (SRVR_USERLISTEND.equals(opcode)) {
if (splittedMessageLine.length < 2) {
System.out.println(
"[MSGBUSMGT, Warning:] Ignoring malformed UE frame: "
LOGGER.warning(
"Ignoring malformed UE frame: "
+ messageToProcess.getMessageText());
return;
}
@@ -1767,7 +1784,7 @@ public class MessageBusManagementThread extends Thread {
//-> LOGSTAT|114|Wrong password!|
if (splittedMessageLine[0].contains(SRVR_LOGSTAT) && splittedMessageLine.length <= 5) {
System.out.println("Passwort falsch!");
LOGGER.warning("ON4KST reported an invalid password");
if (splittedMessageLine[2].contains("password")) {
splittedMessageLine[2] += " pse disc- and reconnect";
@@ -1815,9 +1832,8 @@ public class MessageBusManagementThread extends Thread {
// + messageToProcess.getMessageText() + "\n");
// bufwrtrDBGMSGOut.flush();
System.out.print(new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
+ " [MSGBUSMGT:] Critical, detected unhandled Chatmessage -> "
+ messageToProcess.getMessageText() + "\n");
LOGGER.warning("Detected unhandled ON4KST frame: "
+ messageToProcess.getMessageText());
}
@@ -1915,7 +1931,7 @@ public class MessageBusManagementThread extends Thread {
} else if (categoryNumber.equals(this.client.getChatCategorySecondChat().getCategoryNumber() + "")) {
return this.client.getChatCategorySecondChat();
} else {
System.out.println("Msgbusmgt: ERROR!!! -> category for this message does not exist!");
LOGGER.warning("Category for ON4KST message does not exist; using established main-category fallback");
return this.client.getChatCategoryMain(); //Chatcategory default decision
}
@@ -1955,7 +1971,7 @@ public class MessageBusManagementThread extends Thread {
|| incoming.getSender().getCallSign() == null
|| incoming.getSender().getCallSign().isBlank()) {
System.err.println(
LOGGER.warning(
"KST4Contest auto-answer skipped: "
+ "incoming message has no valid sender callsign."
);
@@ -1970,7 +1986,7 @@ public class MessageBusManagementThread extends Thread {
);
if (qrg == null || qrg.isBlank()) {
System.err.println(
LOGGER.warning(
"KST4Contest QRG auto-answer skipped for "
+ incoming.getSender().getCallSign()
+ ": no QRG is available for chat category "
@@ -1987,7 +2003,7 @@ public class MessageBusManagementThread extends Thread {
.getMessageHandling_autoAnswerTextMainCat();
if (payload == null || payload.isBlank()) {
System.err.println(
LOGGER.warning(
"KST4Contest generic auto-answer skipped for "
+ incoming.getSender().getCallSign()
+ ": the configured answer text is empty."
@@ -2013,7 +2029,7 @@ public class MessageBusManagementThread extends Thread {
return On4KstProtocol.messageText(messageText);
} catch (IllegalArgumentException invalidMessage) {
System.err.println(
LOGGER.warning(
"KST4Contest auto-answer skipped for "
+ incoming.getSender().getCallSign()
+ ": "
@@ -2166,7 +2182,7 @@ public class MessageBusManagementThread extends Thread {
// }
if (messageTextRaw.getMessageText() == null) {
System.out.println("[MSGBUSMGT:] ERROR! got NULL message! BYE!");
LOGGER.severe("Received ON4KST message with null payload");
// this.interrupt();
// break;
}
@@ -2184,35 +2200,38 @@ public class MessageBusManagementThread extends Thread {
if (!On4KstProtocol.isInternalDxqResponse(
messageTextRaw.getMessageText())) {
System.out.println(messageTextRaw.getMessageText() + " <- RXed"); // Stdout at
// Console#######################################################TODO:Wichtig
LOGGER.log(Level.FINE, "ON4KST RX: {0}",
messageTextRaw.getMessageText());
}
try {
processRXMessage23001(messageTextRaw);
} catch (IOException e) {
System.out.println("MsgBusMgt: process23001 went wrong / IO Error");
e.printStackTrace();
LOGGER.log(Level.WARNING,
"I/O error while processing ON4KST frame", e);
} catch (SQLException e) {
System.out.println("MsgBusMgt: process23001 went wrong / SQL Error");
e.printStackTrace();
LOGGER.log(Level.WARNING,
"SQL error while processing ON4KST frame", e);
} catch (RuntimeException e) {
System.out.println("MsgBusMgt: process23001 went wrong / Runtime Error while processing: "
+ messageTextRaw.getMessageText());
e.printStackTrace();
LOGGER.log(Level.WARNING,
"Runtime error while processing ON4KST opcode "
+ On4KstProtocol.opcode(
messageTextRaw.getMessageText()),
e);
}
}
} catch (InterruptedException e1) {
this.interrupt();
e1.printStackTrace();
LOGGER.log(Level.FINE,
"ON4KST message processor interrupted", e1);
break;// TODO Change at may24, avoid uncloadability. Check if this could lead to further errors on instable link!
// client.getMessageRXBus().clear();
}
} // while true end
System.out.println("Msgbusmgt: interrupt");
LOGGER.fine("ON4KST message processor stopped");
this.interrupt();
}
}
@@ -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,
transmitQueue);
MessageHistoryRecorder historyRecorder =
controller.getMessageHistoryRecorder();
ReadThread readThread = new ReadThread(
token, socket, receiveQueue, this::isActiveSession,
line -> onInboundActivity(token, line),
failure -> onConnectionFailure(token, failure));
failure -> onConnectionFailure(token, failure),
historyRecorder);
WriteThread writeThread = new WriteThread(
token, socket, transmitQueue,
mainCategory,
this::isActiveSession,
failure -> onConnectionFailure(token, failure),
controller::onOn4KstOutboundFrameRejected);
controller::onOn4KstOutboundFrameRejected,
historyRecorder);
MessageBusManagementThread messageProcessor =
new MessageBusManagementThread(
controller, controller, token, receiveQueue,
@@ -42,6 +42,33 @@ final class On4KstProtocol {
+ "|25|0|1|" + Math.max(0L, lastMessageTimestamp) + "|0|";
}
/**
* Redacts credentials from a raw login frame while preserving its structure.
* All non-login frames are returned byte-for-character unchanged.
*/
static String redactSecretsForRecording(String frame) {
if (frame == null || !("LOGINC".equals(opcode(frame))
|| "LOGIN".equals(opcode(frame)))) {
return frame;
}
int opcodeSeparator = frame.indexOf('|');
if (opcodeSeparator < 0) {
return frame;
}
int callsignSeparator = frame.indexOf('|', opcodeSeparator + 1);
if (callsignSeparator < 0) {
return frame.substring(0, opcodeSeparator + 1) + "<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. */
static String settingsDone(int category) {
return "SDONE|" + category(category) + "|";
@@ -30,6 +30,7 @@ public class ReadThread extends Thread {
private final LongPredicate sessionIsActive;
private final Consumer<String> inboundActivity;
private final Consumer<Throwable> connectionFailure;
private final MessageHistoryRecorder historyRecorder;
private final BufferedReader reader;
/**
@@ -41,7 +42,8 @@ public class ReadThread extends Thread {
@Deprecated
public ReadThread(Socket socket, ChatController client) throws IOException {
this(0L, socket, client.getMessageRXBus(), ignored -> true,
ignored -> { }, ignored -> { });
ignored -> { }, ignored -> { },
client.getMessageHistoryRecorder());
}
/**
@@ -62,6 +64,21 @@ public class ReadThread extends Thread {
LongPredicate sessionIsActive,
Consumer<String> inboundActivity,
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 {
this.sessionId = sessionId;
this.socket = socket;
@@ -69,6 +86,8 @@ public class ReadThread extends Thread {
this.sessionIsActive = sessionIsActive;
this.inboundActivity = inboundActivity;
this.connectionFailure = connectionFailure;
this.historyRecorder = historyRecorder == null
? MessageHistoryRecorder.disabled() : historyRecorder;
this.reader = new BufferedReader(new InputStreamReader(
socket.getInputStream(), StandardCharsets.UTF_8));
}
@@ -86,6 +105,8 @@ public class ReadThread extends Thread {
throw new EOFException("ON4KST closed the TCP connection");
}
historyRecorder.recordRx(response);
if (!sessionIsActive.test(sessionId)) {
break;
}
@@ -285,7 +285,7 @@ public final class ScoreService {
member.setCurrentPriorityScore(score == null ? 0.0 : score);
}
controller.fireUserListUpdate("Priority scores projected to ChatMember");
controller.fireUserListUpdate();
}
private void updateSelectedScoreFromSnapshot(ScoreSnapshot snap) {
@@ -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
/**
@@ -31,6 +31,7 @@ public class WriteThread extends Thread {
private final LongPredicate sessionIsActive;
private final Consumer<Throwable> connectionFailure;
private final Consumer<String> rejectedFrame;
private final MessageHistoryRecorder historyRecorder;
private final BufferedWriter writer;
private final int defaultCategory;
@@ -45,7 +46,9 @@ public class WriteThread extends Thread {
this(0L, socket, client.getMessageTXBus(),
client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber(),
ignored -> true,
ignored -> { }, System.out::println);
ignored -> { },
message -> LOGGER.log(Level.WARNING, message),
client.getMessageHistoryRecorder());
}
/**
@@ -68,6 +71,22 @@ public class WriteThread extends Thread {
LongPredicate sessionIsActive,
Consumer<Throwable> connectionFailure,
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 {
this.sessionId = sessionId;
this.socket = socket;
@@ -76,6 +95,8 @@ public class WriteThread extends Thread {
this.sessionIsActive = sessionIsActive;
this.connectionFailure = connectionFailure;
this.rejectedFrame = rejectedFrame;
this.historyRecorder = historyRecorder == null
? MessageHistoryRecorder.disabled() : historyRecorder;
this.writer = new BufferedWriter(new OutputStreamWriter(
socket.getOutputStream(), StandardCharsets.UTF_8));
}
@@ -147,6 +168,7 @@ public class WriteThread extends Thread {
writer.write(frame);
writer.write("\r\n");
writer.flush();
historyRecorder.recordTx(frame);
}
private boolean isPoisonPill(ChatMessage message) {
@@ -55,7 +55,7 @@ public class ChatPreferences {
* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults.
*/
// private static final int CONFIG_VERSION = 2;
public static final int CONFIG_VERSION = 7;
public static final int CONFIG_VERSION = 8;
// Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility.
private static final String TAG_CONFIG_VERSION = "configVersion";
@@ -80,6 +80,19 @@ public class ChatPreferences {
*/
public ChatPreferences() {
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, PREFERENCE_RESOURCE, PREFERENCES_FILE);
}
/**
* Creates preferences backed by an explicit file without touching the user
* profile. This is useful for isolated imports, tools and tests.
*
* @param preferencesFile preferences XML path
*/
public ChatPreferences(Path preferencesFile) {
if (preferencesFile == null) {
throw new IllegalArgumentException("Preferences file must not be null");
}
storeAndRestorePreferencesFileName = preferencesFile.toString();
// lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB");
@@ -329,6 +342,7 @@ public class ChatPreferences {
boolean messageHandling_autoAnswerEnabledSecondCat = false;
boolean messageHandling_autoAnswerToQRGRequestEnabled;
private volatile boolean messageHandling_debugModeToFileEnabled = true;
/*********************************************************************************
*
@@ -379,6 +393,17 @@ public class ChatPreferences {
return messageHandling_autoAnswerEnabledSecondCat;
}
public boolean isMessageHandling_debugModeToFileEnabled() {
return messageHandling_debugModeToFileEnabled;
}
public void setMessageHandling_debugModeToFileEnabled(
boolean messageHandling_debugModeToFileEnabled
) {
this.messageHandling_debugModeToFileEnabled =
messageHandling_debugModeToFileEnabled;
}
public void setMessageHandling_autoAnswerEnabledSecondCat(boolean messageHandling_autoAnswerEnabledSecondCat) {
this.messageHandling_autoAnswerEnabledSecondCat = messageHandling_autoAnswerEnabledSecondCat;
}
@@ -2015,6 +2040,12 @@ public class ChatPreferences {
autoAnswerToQrgRequestEnabled.setTextContent(isMessageHandling_autoAnswerToQRGRequestEnabled()+"");
messageHandling.appendChild(autoAnswerToQrgRequestEnabled);
Element debugModeToFileEnabled = doc.createElement(
"messageHandling_debugModeToFileEnabled");
debugModeToFileEnabled.setTextContent(
Boolean.toString(isMessageHandling_debugModeToFileEnabled()));
messageHandling.appendChild(debugModeToFileEnabled);
/****************************
* GUI BEHAVIOUR
***************************/
@@ -2921,6 +2952,7 @@ public class ChatPreferences {
* case messageHandling
*
***********************************************/
messageHandling_debugModeToFileEnabled = true;
Element messageHandlingEl = getFirstElement(doc, "messageHandling");
if (messageHandlingEl != null) {
messageHandling_autoAnswerTextMainCat = getText(
@@ -2950,6 +2982,10 @@ public class ChatPreferences {
messageHandling_autoAnswerEnabledSecondCat,
"messageHandling_autoAnswerEnabledSecondCat",
"autoAnswerEnabledSecondCat");
messageHandling_debugModeToFileEnabled = getBoolean(
messageHandlingEl,
messageHandling_debugModeToFileEnabled,
"messageHandling_debugModeToFileEnabled");
/*
* The user interface intentionally exposes one shared generic auto-answer
@@ -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.nio.file.Path;
import java.util.*;
import java.util.logging.FileHandler;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;
import java.util.function.Consumer;
import java.util.function.Predicate;
@@ -68,6 +66,7 @@ import javafx.scene.shape.Polygon;
import javafx.stage.Screen;
import kst4contest.logic.BandOpportunityResolver;
import kst4contest.utils.ApplicationFileLogging;
import kst4contest.utils.ApplicationFileUtils;
import kst4contest.view.map.StationMapBridge;
import kst4contest.view.map.StationMapView;
@@ -84,6 +83,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
private static final Logger LOGGER = Logger.getLogger(
Kst4ContestApplication.class.getName());
private static ApplicationFileLogging fileLogging;
private static final String SIMPLE_LOG_MANUAL_URL =
"https://kst4contest.hamradioonline.de/manual/en/log-sync/"
+ "#method-1-universal-file-based-callsign-interpreter-simplelogfile";
@@ -6229,7 +6229,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
public void stop() {
System.out.println("[Main.java, Info:] Stage is closing, killing all resources");
LOGGER.info("Application is shutting down and closing all resources");
if (layoutAutosave != null) {
layoutAutosave.flushPending();
}
@@ -6245,8 +6245,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
try {
chatcontroller.disconnect("CLOSEALL");
} catch (Exception e) {
System.out.println("[Main.java, Warning:] Exception during disconnect: " + e.getMessage());
LOGGER.log(Level.WARNING, "Exception during disconnect", e);
}
chatcontroller.getMessageHistoryRecorder().close();
closeFileLogging();
// Platform.exit();
System.exit(0);
@@ -6683,6 +6685,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ChatMember ownChatMemberObject = new ChatMember();
chatcontroller = new ChatController(ownChatMemberObject, this); // instantiate the Chatcontroller with the user object
setDebugFileLoggingEnabled(chatcontroller.getChatPreferences()
.isMessageHandling_debugModeToFileEnabled());
layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences());
messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences());
chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual
@@ -11475,6 +11479,31 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
grdPnlMessageHandlingBeacon.add(chkbx_messageHandlingAutoQRGInfoEnabled, 0, 2, 2, 1);
grdPnlMessageHandlingBeacon.add(generateLabeledSeparator(
100, "Debug and contest history"), 0, 3, 2, 1);
CheckBox chkbxDebugModeToFile =
new CheckBox("Enable debug mode to file");
chkbxDebugModeToFile.setSelected(chatcontroller.getChatPreferences()
.isMessageHandling_debugModeToFileEnabled());
chkbxDebugModeToFile.setTooltip(new Tooltip(
"Records complete ON4KST RX/TX session traffic in Messagehistory.raw "
+ "and adds diagnostic details to kst4contest-errors.log.\n"
+ "Login passwords are redacted. Warnings and errors are always logged."));
chkbxDebugModeToFile.selectedProperty().addListener(
(observable, oldValue, enabled) -> {
chatcontroller.getChatPreferences()
.setMessageHandling_debugModeToFileEnabled(enabled);
if (enabled) {
setDebugFileLoggingEnabled(true);
chatcontroller.setMessageHistoryRecordingEnabled(true);
} else {
chatcontroller.setMessageHistoryRecordingEnabled(false);
setDebugFileLoggingEnabled(false);
}
});
grdPnlMessageHandlingBeacon.add(chkbxDebugModeToFile, 0, 4, 2, 1);
VBox vbxMsgHandlBeacon = new VBox();
vbxMsgHandlBeacon.setPadding(new Insets(10, 10, 10, 10));
vbxMsgHandlBeacon.getChildren().addAll(grdPnlMessageHandlingBeacon);
@@ -12218,20 +12247,33 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
private static void setupFileLogging() {
if (fileLogging != null) {
return;
}
try {
String logDir = Path.of(System.getProperty("user.home"), ".praktiKST").toString();
new File(logDir).mkdirs();
FileHandler fileHandler = new FileHandler(logDir + "/kst4contest-errors.log", true);
// Connection loss/reconnect diagnostics are warnings, not fatal crashes.
fileHandler.setLevel(Level.WARNING);
fileHandler.setFormatter(new SimpleFormatter());
Logger rootLogger = Logger.getLogger("");
rootLogger.addHandler(fileHandler);
Path logFile = Path.of(ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME,
"kst4contest-errors.log"));
// Start conservatively until the persisted preference has been loaded.
fileLogging = new ApplicationFileLogging(logFile, false);
} catch (IOException e) {
System.err.println("Could not set up file logging: " + e.getMessage());
}
}
private static void setDebugFileLoggingEnabled(boolean enabled) {
if (fileLogging != null) {
fileLogging.setDebugEnabled(enabled);
}
}
private static void closeFileLogging() {
if (fileLogging != null) {
fileLogging.close();
fileLogging = null;
}
}
@Override
public void onThreadStatusChanged(
String key,
@@ -12351,13 +12393,27 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
@Override
public void onUserListUpdated(String reason) {
public void onUserListUpdated() {
scheduleUserListRefresh(null);
}
@Override
public void onUserListUpdated(final String reason) {
scheduleUserListRefresh(reason);
}
private void scheduleUserListRefresh(final String diagnosticReason) {
Platform.runLater(() -> {
pendingUserListUpdateReason = reason;
if (diagnosticReason != null && !diagnosticReason.isBlank()) {
pendingUserListUpdateReason = diagnosticReason;
}
if (userListRefreshCoalescer == null) {
userListRefreshCoalescer = new PauseTransition(Duration.millis(300));
userListRefreshCoalescer.setOnFinished(event -> {
final String completedDiagnosticReason = pendingUserListUpdateReason;
pendingUserListUpdateReason = "";
forceChatMemberFilterRefresh();
if (tbl_chatMember != null) {
@@ -12366,7 +12422,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
refreshStationMapIfVisible();
System.out.println("KST4Capp, UI Update Trigger: " + pendingUserListUpdateReason);
if (!completedDiagnosticReason.isEmpty()) {
System.out.println("KST4Capp, UI Update Trigger: "
+ completedDiagnosticReason);
}
});
}
+3 -1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<praktiKST>
<configVersion>8</configVersion>
<station>
<LoginCallSign>DO5AMF</LoginCallSign>
<LoginPassword>changeMe</LoginPassword>
@@ -128,6 +129,7 @@
<autoAnswerTextSecondCat>Hi, sry I am not qrv, just testing new features of KST4Contest 1.251</autoAnswerTextSecondCat>
<autoAnswerEnabledSecondCat>false</autoAnswerEnabledSecondCat>
<autoAnswerToQrgRequestEnabled>true</autoAnswerToQrgRequestEnabled>
<messageHandling_debugModeToFileEnabled>true</messageHandling_debugModeToFileEnabled>
</messageHandling>
<guiSaveableOptions>
<guiOptions_defaultFilterNothing>false</guiOptions_defaultFilterNothing>
@@ -146,4 +148,4 @@
<GUImainWindowRightSplitPane_dividerposition>0.5749241274658573</GUImainWindowRightSplitPane_dividerposition>
<GUIpnl_directedMSGWin_dividerpositionDefault>0.1754658301944221</GUIpnl_directedMSGWin_dividerpositionDefault>
</guiOptions>
</praktiKST>
</praktiKST>
@@ -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"));
assertTrue(writtenXml.contains("<futureExtension mode=\"keep-me\">"));
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("<GUIstationMapClusteringEnabled>false"
+ "</GUIstationMapClusteringEnabled>"));
@@ -127,8 +127,6 @@ class ChatPreferencesLayoutPersistenceTest {
}
private ChatPreferences preferencesAt(Path preferencesFile) {
ChatPreferences preferences = new ChatPreferences();
preferences.setStoreAndRestorePreferencesFileName(preferencesFile.toString());
return preferences;
return new ChatPreferences(preferencesFile);
}
}
@@ -18,7 +18,9 @@ class ChatPreferencesStationMapClusteringTest {
@Test
void clusteringIsEnabledByDefault() {
assertTrue(new ChatPreferences().isGUIstationMapClusteringEnabled());
assertTrue(new ChatPreferences(
temporaryDirectory.resolve("unused-default.xml"))
.isGUIstationMapClusteringEnabled());
}
@Test
@@ -30,7 +32,7 @@ class ChatPreferencesStationMapClusteringTest {
assertTrue(written.writePreferencesToXmlFile());
String writtenXml = Files.readString(preferencesFile);
assertTrue(writtenXml.contains("<configVersion>7</configVersion>"));
assertTrue(writtenXml.contains("<configVersion>8</configVersion>"));
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
+ "</GUIstationMapClusteringEnabled>"));
@@ -80,7 +82,7 @@ class ChatPreferencesStationMapClusteringTest {
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>7</configVersion>
<configVersion>8</configVersion>
<guiOptions>
<GUIstationMapClusteringEnabled>sometimes</GUIstationMapClusteringEnabled>
</guiOptions>
@@ -94,8 +96,6 @@ class ChatPreferencesStationMapClusteringTest {
}
private ChatPreferences preferencesAt(Path preferencesFile) {
ChatPreferences preferences = new ChatPreferences();
preferences.setStoreAndRestorePreferencesFileName(preferencesFile.toString());
return preferences;
return new ChatPreferences(preferencesFile);
}
}
@@ -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"));
}
}