mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-18 21:34:03 +02:00
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:
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user