14 changed files with 2210 additions and 399 deletions
+1
View File
@@ -445,6 +445,7 @@
<addmodule>java.sql</addmodule>
<addmodule>java.net.http</addmodule>
<addmodule>jdk.crypto.ec</addmodule>
<addmodule>jdk.net</addmodule>
</addmodules>
<mainclass>${main.class}</mainclass>
<input>${project.build.directory}/modules</input>
@@ -35,6 +35,8 @@ import java.util.function.Consumer;
import java.util.function.Predicate;
import java.nio.charset.StandardCharsets;
import kst4contest.logic.FrequencyTextParser;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -57,6 +59,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
*
*/
private static final Logger LOGGER =
Logger.getLogger(ChatController.class.getName());
private static final boolean DEBUG_BAND_UPGRADE_HINT = true; //for new band hint
@@ -86,6 +91,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
boolean disconnected;
boolean disconnectionPerformedByUser = false;
private final On4KstConnectionManager on4KstConnectionManager =
new On4KstConnectionManager(this);
public boolean isDisconnectionPerformedByUser() {
return disconnectionPerformedByUser;
@@ -140,6 +148,15 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return disconnected;
}
/**
* Returns the authoritative ON4KST lifecycle state.
*
* @return current connection, authentication or synchronization state
*/
public On4KstConnectionState getOn4KstConnectionState() {
return on4KstConnectionManager.getState();
}
public void setDisconnected(boolean disconnected) {
this.disconnected = disconnected;
}
@@ -161,6 +178,112 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
} else System.out.println("ERRRRRRRRRRRRRRRRRRRRRRRRRRRÖRRRRRRRRRRRRRRRRRRR");
}
/**
* Publishes one authoritative connection-state transition to legacy controller
* flags, generic worker status listeners and the dedicated UI callback.
*
* <p>The compatibility flags remain derived values. No caller may set them to
* infer socket health; only the connection manager owns that decision.</p>
*
* @param state new lifecycle state
* @param detail human-readable progress or failure reason
* @param critical whether the transition should be emphasized as an error
*/
void updateOn4KstConnectionState(
On4KstConnectionState state,
String detail,
boolean critical
) {
setConnectedAndLoggedIn(state == On4KstConnectionState.ONLINE);
setConnectedAndNOTLoggedIn(
state.isConnectionAttemptActive()
&& state != On4KstConnectionState.ONLINE);
setDisconnected(state == On4KstConnectionState.DISCONNECTED);
ThreadStateMessage status = new ThreadStateMessage(
"ON4KST", state.isConnectionAttemptActive(), detail, critical);
status.setRunningInformationTextDescription(state.name());
onThreadStatus("ON4KST", status);
if (statusListener != null) {
statusListener.onConnectionStateChanged(state, detail);
}
}
/**
* Installs all resources belonging to one successfully opened connection
* generation.
*
* <p>The session-id check prevents a slow connection attempt from overwriting a
* newer socket and its queues.</p>
*/
synchronized void installOn4KstSession(
long connectionSessionId,
Socket sessionSocket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LinkedBlockingQueue<ChatMessage> transmitQueue,
ReadThread sessionReadThread,
WriteThread sessionWriteThread,
MessageBusManagementThread sessionMessageProcessor
) {
if (!on4KstConnectionManager.isActiveSession(connectionSessionId)) {
return;
}
this.socket = sessionSocket;
this.messageRXBus = receiveQueue;
this.messageTXBus = transmitQueue;
this.readThread = sessionReadThread;
this.writeThread = sessionWriteThread;
this.messageProcessor = sessionMessageProcessor;
}
void onOn4KstLogstat(long connectionSessionId, String[] fields) {
on4KstConnectionManager.onLogstat(connectionSessionId, fields);
}
void stageInitialOn4KstChatMember(
long connectionSessionId,
ChatMember member
) {
on4KstConnectionManager.stageInitialChatMember(
connectionSessionId, member);
}
void onOn4KstInitialUserListCompleted(
long connectionSessionId,
ChatCategory category
) {
on4KstConnectionManager.onInitialUserListCompleted(
connectionSessionId, category);
}
void onOn4KstConnectionOnline() {
scheduleBeaconTimer(INITIAL_BEACON_DELAY_MILLIS);
}
void onOn4KstConnectionLost() {
stopBeaconTimer();
}
void onOn4KstOutboundFrameRejected(String reason) {
onOn4KstConnectionWarning("Outbound frame rejected locally: " + reason);
}
/**
* Receives a non-fatal ON4KST diagnostic, writes it to the persistent warning
* log and forwards it to the status UI.
*
* @param warning sanitized diagnostic text; passwords and raw chat frames must
* never be included
*/
void onOn4KstConnectionWarning(String warning) {
LOGGER.log(Level.WARNING, "ON4KST warning: {0}", warning);
ThreadStateMessage status = new ThreadStateMessage(
"ON4KST", true, warning, false);
status.setRunningInformationTextDescription("WARNING");
onThreadStatus("ON4KST", status);
}
/********************************************************************************
* PSTRotator controlling
@@ -573,6 +696,16 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
*/
public void disconnect(String action) {
/*
* New connections are owned by the session manager. Keep the historic cleanup
* below as a compatibility fallback, but do not let it manipulate resources
* belonging to a replacement session.
*/
if (on4KstConnectionManager != null) {
disconnectManaged(action);
return;
}
// stopContextLoop(); //stops thread for calculating sked priorities
stopScoreScheduler();
@@ -757,10 +890,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (Exception e2) {
// TODO Auto-generated catch block
e2.printStackTrace();
}
@@ -768,6 +899,69 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
}
private void disconnectManaged(String action) {
setDisconnectionPerformedByUser(true);
on4KstConnectionManager.stopByUser();
clearActiveChatMembers();
runOnFxThread(() -> lst_clusterMemberList.clear());
stopBeaconTimer();
stopScoreScheduler();
stopDxClusterServer();
cancelTimer(userActualizationtimer);
userActualizationtimer = null;
cancelTimer(ASQueryTimer);
ASQueryTimer = null;
stopUdpReader(readUDPbyUCXThread,
chatPreferences.getLogsynch_ucxUDPWkdCallListenerPort());
readUDPbyUCXThread = null;
stopUdpReader(readUDPByWintestThread,
chatPreferences.getLogsynch_wintestNetworkPort());
stopWintestUdpListener();
stopUdpReader(airScoutUDPReaderThread,
chatPreferences.getAirScout_asCommunicationPort());
airScoutUDPReaderThread = null;
if (ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE.equals(action)) {
if (dbHandler != null) {
dbHandler.closeDBConnection();
}
if (rotatorClient != null) {
rotatorClient.stopRotor();
rotatorClient.stop();
rotatorClient = null;
}
}
}
private void cancelTimer(Timer timer) {
if (timer != null) {
timer.cancel();
timer.purge();
}
}
private void stopUdpReader(Thread readerThread, int listenerPort) {
if (readerThread != null) {
readerThread.interrupt();
}
try (DatagramSocket datagramSocket = new DatagramSocket()) {
datagramSocket.setBroadcast(true);
byte[] poison = ApplicationConstants.DISCONNECT_RDR_POISONPILL.getBytes(
StandardCharsets.UTF_8);
DatagramPacket packet = new DatagramPacket(
poison, poison.length,
InetAddress.getByName("255.255.255.255"), listenerPort);
datagramSocket.send(packet);
} catch (IOException exception) {
System.out.println("[ChatController, warning]: could not wake UDP reader on port "
+ listenerPort + ": " + exception.getMessage());
}
}
// private ObservableList<ContestSked> activeSkeds = FXCollections.observableArrayList();
// public ObservableList<ContestSked> getActiveSkeds() {
// return activeSkeds;
@@ -1416,6 +1610,10 @@ private ObservableList<String>
private final List<ChatMessage> pendingChatMessages = new ArrayList<>();
private boolean chatMessageFlushScheduled = false;
private static final int RECENT_INBOUND_MESSAGE_KEYS_MAX = 5_000;
private final LinkedHashMap<String, Boolean> recentInboundMessageKeys =
new LinkedHashMap<>();
/*
* Same idea for DXCluster messages.
*/
@@ -1572,6 +1770,55 @@ private ObservableList<String>
runOnFxThread(() -> lst_chatMemberList.clear());
}
/**
* Atomically replaces one category after its terminating UE frame arrived.
* Partial UA0 snapshots are never exposed to the TableView.
*
* @param connectionSessionId session that produced the completed snapshot
* @param category chat category whose members are being replaced
* @param completeMembers validated members staged before the UE terminator
*/
public void replaceActiveChatMembersForCategory(
long connectionSessionId,
ChatCategory category,
Collection<ChatMember> completeMembers
) {
if (category == null
|| !on4KstConnectionManager.isActiveSession(connectionSessionId)) {
return;
}
int categoryNumber = category.getCategoryNumber();
List<ChatMember> safeMembers = completeMembers == null
? List.of() : new ArrayList<>(completeMembers);
for (ChatMember member : safeMembers) {
initializeFrequencyFromStationNameIfUnambiguous(member);
}
activeChatMembersByCallAndCategory.entrySet().removeIf(entry -> {
ChatMember member = entry.getValue();
return member != null && member.getChatCategory() != null
&& member.getChatCategory().getCategoryNumber() == categoryNumber;
});
for (ChatMember member : safeMembers) {
String key = buildActiveChatMemberKey(member);
if (key != null) {
activeChatMembersByCallAndCategory.put(key, member);
}
}
runOnFxThread(() -> {
if (!on4KstConnectionManager.isActiveSession(connectionSessionId)) {
return;
}
lst_chatMemberList.removeIf(member -> member != null
&& member.getChatCategory() != null
&& member.getChatCategory().getCategoryNumber() == categoryNumber);
lst_chatMemberList.addAll(safeMembers);
fireUserListUpdate("Complete ON4KST user list received");
});
}
/**
* Resolves a member from the thread-safe active model. This avoids reading the
* TableView backing list from MessageBusManagementThread.
@@ -1913,6 +2160,9 @@ private ObservableList<String>
if (message == null) {
return;
}
if (isDuplicateInboundMessage(message)) {
return;
}
synchronized (pendingChatMessagesLock) {
pendingChatMessages.add(message);
@@ -1927,6 +2177,47 @@ private ObservableList<String>
Platform.runLater(this::flushPendingChatMessagesToUi);
}
/**
* Suppresses the small replay overlap deliberately requested after a reconnect.
*
* <p>The manager asks for messages beginning one timestamp before the last known
* message so that a boundary message cannot be lost. This bounded key cache
* removes the expected duplicate without growing for the lifetime of the
* application.</p>
*/
private boolean isDuplicateInboundMessage(ChatMessage message) {
String timestamp = message.getMessageGeneratedTime();
if (timestamp == null || timestamp.isBlank()) {
return false;
}
String sender = message.getSender() == null
? "" : String.valueOf(message.getSender().getCallSign());
String receiver = message.getReceiver() == null
? "" : String.valueOf(message.getReceiver().getCallSign());
int category = message.getChatCategory() == null
? -1 : message.getChatCategory().getCategoryNumber();
String key = category + "|" + timestamp + "|" + sender + "|"
+ receiver + "|" + String.valueOf(message.getMessageText());
synchronized (recentInboundMessageKeys) {
if (recentInboundMessageKeys.containsKey(key)) {
return true;
}
recentInboundMessageKeys.put(key, Boolean.TRUE);
while (recentInboundMessageKeys.size()
> RECENT_INBOUND_MESSAGE_KEYS_MAX) {
Iterator<String> iterator = recentInboundMessageKeys.keySet().iterator();
if (!iterator.hasNext()) {
break;
}
iterator.next();
iterator.remove();
}
return false;
}
}
private void flushPendingChatMessagesToUi() {
List<ChatMessage> batch;
@@ -2615,6 +2906,10 @@ private ObservableList<String>
*/
public void execute() throws InterruptedException, IOException {
if (on4KstConnectionManager != null) {
executeManaged();
return;
}
chatController = this;
@@ -2837,6 +3132,50 @@ private ObservableList<String>
}
private synchronized void executeManaged() throws IOException {
chatController = this;
setDisconnectionPerformedByUser(false);
startScoreScheduler();
if (readUDPbyUCXThread == null || !readUDPbyUCXThread.isAlive()) {
readUDPbyUCXThread = new ReadUDPbyUCXMessageThread(
chatPreferences.getLogsynch_ucxUDPWkdCallListenerPort(),
this, this);
readUDPbyUCXThread.setName("readUDPbyUCXThread");
readUDPbyUCXThread.start();
}
if (chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) {
startWintestUdpListener();
}
if (airScoutUDPReaderThread == null || !airScoutUDPReaderThread.isAlive()) {
airScoutUDPReaderThread = new ReadUDPbyAirScoutMessageThread(
chatPreferences.getAirScout_asCommunicationPort(),
this, this);
airScoutUDPReaderThread.setName("airscoutudpreaderThread");
airScoutUDPReaderThread.start();
}
cancelTimer(userActualizationtimer);
userActualizationtimer = new Timer("UserActualizationTimer", true);
userActualizationtimer.schedule(
new UserActualizationTask(this), 4_000L, 60_000L);
cancelTimer(ASQueryTimer);
ASQueryTimer = new Timer("AirScoutQueryTimer", true);
ASQueryTimer.schedule(
new AirScoutPeriodicalAPReflectionInquirerTask(this),
10_000L, 60_000L);
if (chatPreferences.isStn_pstRotatorEnabled() && rotatorClient == null) {
initRotor();
}
startDxClusterServerIfEnabled();
on4KstConnectionManager.start();
}
/**
* Returns the background reachability service used by the station table.
@@ -2964,6 +3303,11 @@ private ObservableList<String>
*/
public void initialize23001() throws InterruptedException, IOException {
if (on4KstConnectionManager != null) {
on4KstConnectionManager.start();
return;
}
messageTXBus.clear();
ChatMessage message = new ChatMessage();
@@ -19,6 +19,8 @@ import kst4contest.locatorUtils.DirectionUtils;
import kst4contest.locatorUtils.Location;
import kst4contest.model.*;
import kst4contest.logic.FrequencyTextParser;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.LongPredicate;
/**
*
@@ -37,6 +39,11 @@ public class MessageBusManagementThread extends Thread {
private PrintWriter writer;
// private Socket socket;
private ChatController client;
private final long connectionSessionId;
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LongPredicate connectionSessionIsActive;
// private File fileLogRAW;
// private TimerTask userActualizationTask; // Is used as a temporary userout-print
// private TimerTask userActualizationTask; //kst4contest.test 4 23001
@@ -118,9 +125,22 @@ public class MessageBusManagementThread extends Thread {
}
public MessageBusManagementThread(ChatController client, ThreadStatusCallback callBack) {
this(client, callBack, 0L, client.getMessageRXBus(), ignored -> true);
}
public MessageBusManagementThread(
ChatController client,
ThreadStatusCallback callBack,
long connectionSessionId,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LongPredicate connectionSessionIsActive
) {
this.callBackToController = callBack;
this.client = client;
this.connectionSessionId = connectionSessionId;
this.receiveQueue = receiveQueue;
this.connectionSessionIsActive = connectionSessionIsActive;
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
@@ -522,7 +542,8 @@ public class MessageBusManagementThread extends Thread {
messageToProcess.setMessageText(reduce);
if (messageToProcess.getMessageText().isEmpty()) {
if (messageToProcess.getMessageText() == null
|| messageToProcess.getMessageText().isEmpty()) {
// System.out.println("[MSGBUSMGTT:] ###################### no processable data");
} else {
@@ -754,6 +775,50 @@ public class MessageBusManagementThread extends Thread {
return fallbackReceiver;
}
private boolean validateInboundUserFrame(String[] fields) {
if (fields == null || fields.length < 6) {
logRejectedInboundUserFrame(
"Ignoring truncated ON4KST user frame", fields);
return false;
}
if (fields[2] == null || fields[2].isBlank()) {
logRejectedInboundUserFrame(
"Ignoring ON4KST user frame with empty callsign", fields);
return false;
}
try {
On4KstProtocol.category(Integer.parseInt(fields[1]));
On4KstProtocol.locator(fields[4]);
Integer.parseInt(fields[5]);
return true;
} catch (IllegalArgumentException invalidUser) {
logRejectedInboundUserFrame(
"Ignoring malformed ON4KST user '" + fields[2]
+ "': " + invalidUser.getMessage(), fields);
return false;
}
}
/**
* Records a rejected user frame without writing the complete raw frame or user
* name to the diagnostic log.
*
* <p>The opcode, category and callsign are sufficient to identify the offending
* list position. Omitting the remaining fields avoids unnecessary disclosure of
* free-form profile text.</p>
*/
private void logRejectedInboundUserFrame(String reason, String[] fields) {
String opcode = fields != null && fields.length > 0 ? fields[0] : "UNKNOWN";
String category = fields != null && fields.length > 1 ? fields[1] : "UNKNOWN";
String callsign = fields != null && fields.length > 2 ? fields[2] : "UNKNOWN";
client.onOn4KstConnectionWarning(
reason + "; opcode=" + opcode
+ ", category=" + category
+ ", callsign=" + callsign
+ ", fieldCount=" + (fields == null ? 0 : fields.length));
}
/**
* Processes received messages via port 23001 (improved telnet Interface)
*
@@ -808,23 +873,33 @@ public class MessageBusManagementThread extends Thread {
* here we have a helper list for identifying questions for my qrg which can be autoanswered later
*/
if (messageToProcess.getMessageText().isEmpty()) {
// System.out.println("[MSGBUSMGTT:] no processable data");
if (messageToProcess.getMessageText() == null
|| messageToProcess.getMessageText().isEmpty()) {
// No processable data.
} else {
if (messageToProcess.getMessageText().contains(SRVR_LOGSTAT)) {
String logstatMessage[];
logstatMessage = messageToProcess.getMessageText().split("\\|");
if (logstatMessage[1].contains(SRVR_LOGINOK)) {
this.client.setConnectedAndLoggedIn(true);
} else {
this.client.setConnectedAndNOTLoggedIn(true);
this.client.setConnectedAndLoggedIn(false);
}
if (messageToProcess.getMessageText().startsWith(SRVR_LOGSTAT + "|")) {
String[] logstatMessage =
messageToProcess.getMessageText().split("\\|", -1);
this.client.onOn4KstLogstat(
connectionSessionId,
logstatMessage);
}
String splittedMessageLine[] = messageToProcess.getMessageText().split("\\|");
String[] splittedMessageLine =
messageToProcess.getMessageText().split("\\|");
String opcode = splittedMessageLine.length == 0
? ""
: splittedMessageLine[0];
if ((INITIALUSERLISTENTRY.equals(opcode)
|| USERENTEREDCHAT.equals(opcode)
|| USERENTEREDCHAT2.equals(opcode))
&& !validateInboundUserFrame(splittedMessageLine)) {
return;
}
// String splittedMessageLine[] = messageToProcess.getMessageText().split("\\|");
/**
* Initializes the Userlist if entry fits UA0
@@ -832,7 +907,7 @@ public class MessageBusManagementThread extends Thread {
*
*
*/
if (splittedMessageLine[0].contains(INITIALUSERLISTENTRY)) {
if (splittedMessageLine[0].equals(INITIALUSERLISTENTRY)) {
// System.out.println("MSGBUS: User detected");
ChatMember newMember = new ChatMember();
@@ -853,7 +928,9 @@ public class MessageBusManagementThread extends Thread {
if (!client.getChatPreferences().getStn_loginCallSign().equals(newMember.getCallSign())) {
this.client.addOrUpdateActiveChatMember(newMember); // the own call will not be in the list
this.client.stageInitialOn4KstChatMember(
connectionSessionId,
newMember);
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
// Reachability is calculated on demand only: map click, selected station, or manual request.
}
@@ -877,7 +954,8 @@ public class MessageBusManagementThread extends Thread {
* UA2|2|W5ADD|Parker|EM40WL|2|
*
*/
if (splittedMessageLine[0].contains(USERENTEREDCHAT) || splittedMessageLine[0].contains(USERENTEREDCHAT2)) {
if (splittedMessageLine[0].equals(USERENTEREDCHAT)
|| splittedMessageLine[0].equals(USERENTEREDCHAT2)) {
// System.out.println("MSGBUS: User detected");
@@ -1640,18 +1718,29 @@ public class MessageBusManagementThread extends Thread {
/**
* Userinfo-update: UE|2|22562|
*/
if (splittedMessageLine[0].contains(SRVR_USERLISTEND)) {
if (SRVR_USERLISTEND.equals(opcode)) {
if (splittedMessageLine.length < 2) {
System.out.println(
"[MSGBUSMGT, Warning:] Ignoring malformed UE frame: "
+ messageToProcess.getMessageText());
return;
}
// No worthy information, count of users
} else
this.client.onOn4KstInitialUserListCompleted(
connectionSessionId,
util_getChatCategoryByCategoryNrString(
splittedMessageLine[1]));
if (splittedMessageLine[0].contains(SRVR_DXCEND)) {
} else if (SRVR_DXCEND.equals(opcode)) {
// No worthy information, count of users
} else
// DF marks the end of the initial DX-cluster data.
// The frame contains no data that needs to be published.
} else if (SRVR_COMMUNICATIONK.equals(opcode)) {
// CK is a regular server delimiter/acknowledgement.
// It is intentionally accepted without further processing.
if (splittedMessageLine[0].contains(SRVR_COMMUNICATIONK)) {
// No worthy information, end of srvrmsgs
} else
//-> LOGSTAT|114|Wrong password!|
@@ -1931,13 +2020,17 @@ public class MessageBusManagementThread extends Thread {
while (true) {
try {
messageTextRaw = client.getMessageRXBus().take();
messageTextRaw = receiveQueue.take();
if (messageTextRaw.getMessageText().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL) && messageTextRaw.getMessageSenderName().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
client.getMessageRXBus().clear();
if (ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(messageTextRaw.getMessageText())
&& ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(messageTextRaw.getMessageSenderName())) {
receiveQueue.clear();
break;
}
else {
if (!connectionSessionIsActive.test(connectionSessionId)) {
break;
}
messageLine = messageTextRaw.getMessageText();
/***********************************************
@@ -0,0 +1,837 @@
package kst4contest.controller;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Level;
import java.util.logging.Logger;
import jdk.net.ExtendedSocketOptions;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
/**
* Owns the complete lifecycle of the single ON4KST TCP session.
*
* <p>Every reader, writer, queue and parser belongs to an immutable session id.
* A delayed failure from an old socket can therefore never close or consume data
* from its replacement.</p>
*/
final class On4KstConnectionManager {
private static final Logger LOGGER =
Logger.getLogger(On4KstConnectionManager.class.getName());
private static final DateTimeFormatter LIVE_MESSAGE_TIMESTAMP =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
static final int CONNECT_TIMEOUT_MILLIS = 10_000; //TCP-Connect-Timeout
static final long LOGIN_FALLBACK_MILLIS = 2_000L; //Login-Fallback
static final long HANDSHAKE_TIMEOUT_MILLIS = 45_000L; //Handshake-Timeout
static final long APPLICATION_HEARTBEAT_AFTER_MILLIS = 90_000L; //Application-Heartbeat
static final long INBOUND_STALE_AFTER_MILLIS = 210_000L; //Stale-Timeout - time without rxed data
static final List<Long> RECONNECT_DELAYS_MILLIS =
List.of(2_000L, 5_000L, 10_000L, 20_000L, 30_000L); //Reconnect-Backoff if no connection possible
private final ChatController controller;
private final ScheduledExecutorService scheduler;
private final AtomicLong generation = new AtomicLong();
private final AtomicLong lastReceivedMessageTimestamp = new AtomicLong();
private volatile Session activeSession;
private volatile On4KstConnectionState state =
On4KstConnectionState.DISCONNECTED;
private volatile boolean stopRequested = true;
private int reconnectAttempt;
On4KstConnectionManager(ChatController controller) {
this.controller = controller;
this.scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "On4KstConnectionSupervisor");
thread.setDaemon(true);
return thread;
});
this.scheduler.scheduleAtFixedRate(
this::monitorActiveSession, 5L, 5L, TimeUnit.SECONDS);
LOGGER.fine("ON4KST connection supervisor initialized");
}
/**
* Returns the last lifecycle state published by the connection supervisor.
*
* @return current immutable connection-state value
*/
On4KstConnectionState getState() {
return state;
}
/**
* Verifies that a callback still belongs to the currently installed session.
*
* <p>Every reconnect receives a new id. Late EOF, write or parser callbacks from
* an obsolete socket therefore become harmless instead of closing the replacement
* connection.</p>
*
* @param sessionId id captured by the calling worker
* @return {@code true} only for the current, open and non-stopped session
*/
boolean isActiveSession(long sessionId) {
Session session = activeSession;
return session != null
&& session.id == sessionId
&& !session.closed
&& !stopRequested;
}
/**
* Starts a non-blocking connection attempt.
*
* <p>Configuration is validated before a socket is opened. A duplicate Connect
* action is ignored while another attempt or session is active. Connection work
* runs on the supervisor executor, so an unreachable server cannot block the
* JavaFX application thread.</p>
*/
void start() {
long token;
synchronized (this) {
if (!stopRequested && state.isConnectionAttemptActive()) {
return;
}
try {
validateConfiguration();
} catch (IllegalArgumentException invalidConfiguration) {
stopRequested = true;
transition(On4KstConnectionState.DISCONNECTED,
"Invalid ON4KST configuration: "
+ invalidConfiguration.getMessage(), true);
return;
}
stopRequested = false;
reconnectAttempt = 0;
token = generation.incrementAndGet();
transition(On4KstConnectionState.CONNECTING,
"Opening ON4KST connection", false);
}
scheduler.execute(() -> openConnection(token));
}
/**
* Stops the current session and invalidates every scheduled callback or reconnect
* belonging to it.
*/
void stopByUser() {
Session oldSession;
synchronized (this) {
stopRequested = true;
generation.incrementAndGet();
transition(On4KstConnectionState.STOPPING,
"Disconnecting from ON4KST", false);
oldSession = activeSession;
activeSession = null;
}
closeSession(oldSession);
controller.onOn4KstConnectionLost();
transition(On4KstConnectionState.DISCONNECTED,
"Disconnected by user", false);
}
/**
* Records one received protocol line as proof of application-level liveness.
*
* <p>TCP's {@code isConnected()} only states that a connection once succeeded.
* It does not prove that the peer is still reachable. Updating the inbound
* timestamp here gives the monitor a meaningful end-to-end signal.</p>
*
* @param sessionId immutable source-session id
* @param line complete protocol line received from ON4KST
*/
void onInboundActivity(long sessionId, String line) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.closed) {
return;
}
long now = System.currentTimeMillis();
session.lastInboundMillis.set(now);
session.lastProgressMillis.set(now);
String opcode = opcode(line);
if ("CK".equals(opcode)) {
sendHeartbeat(session);
}
if (!session.loginSent
&& line != null
&& line.toLowerCase(Locale.ROOT).contains("login")) {
scheduler.execute(() -> sendLogin(sessionId));
}
if ("CH".equals(opcode) || "CR".equals(opcode)) {
recordHistoryTimestamp(line);
}
}
void onLogstat(long sessionId, String[] fields) {
String[] copy = fields == null ? new String[0] : fields.clone();
scheduler.execute(() -> handleLogstat(sessionId, copy));
}
void stageInitialChatMember(long sessionId, ChatMember member) {
Session session = activeSession;
if (session == null || session.id != sessionId || member == null
|| member.getChatCategory() == null || member.getCallSign() == null) {
return;
}
int category = member.getChatCategory().getCategoryNumber();
session.initialMembers
.computeIfAbsent(category, ignored -> new ConcurrentHashMap<>())
.put(member.getCallSign().trim().toUpperCase(Locale.ROOT), member);
session.lastProgressMillis.set(System.currentTimeMillis());
}
void onInitialUserListCompleted(long sessionId, ChatCategory category) {
if (category == null) {
return;
}
scheduler.execute(() -> completeInitialUserList(
sessionId, category.getCategoryNumber()));
}
private void openConnection(long token) {
if (!mayOpen(token)) {
return;
}
LOGGER.log(Level.INFO,
"Opening ON4KST TCP session {0}", token);
Socket socket = new Socket();
try {
ChatPreferences preferences = controller.getChatPreferences();
socket.connect(new InetSocketAddress(
preferences.getStn_on4kstServersDns(),
preferences.getStn_on4kstServersPort()),
CONNECT_TIMEOUT_MILLIS);
configureSocket(socket);
LOGGER.log(Level.INFO,
"ON4KST TCP session {0} connected to {1}",
new Object[] {token, socket.getRemoteSocketAddress()});
LinkedBlockingQueue<ChatMessage> receiveQueue =
new LinkedBlockingQueue<>();
LinkedBlockingQueue<ChatMessage> transmitQueue =
new LinkedBlockingQueue<>();
Session session = new Session(token, socket, receiveQueue, transmitQueue);
ReadThread readThread = new ReadThread(
token, socket, receiveQueue, this::isActiveSession,
line -> onInboundActivity(token, line),
failure -> onConnectionFailure(token, failure));
WriteThread writeThread = new WriteThread(
token, socket, transmitQueue,
controller.getChatPreferences().getLoginChatCategoryMain()
.getCategoryNumber(),
this::isActiveSession,
failure -> onConnectionFailure(token, failure),
controller::onOn4KstOutboundFrameRejected);
MessageBusManagementThread messageProcessor =
new MessageBusManagementThread(
controller, controller, token, receiveQueue,
this::isActiveSession);
session.readThread = readThread;
session.writeThread = writeThread;
session.messageProcessor = messageProcessor;
synchronized (this) {
if (!mayOpen(token)) {
closeSession(session);
return;
}
activeSession = session;
controller.installOn4KstSession(
token, socket, receiveQueue, transmitQueue,
readThread, writeThread, messageProcessor);
transition(On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
"TCP connected; waiting for ON4KST login prompt", false);
}
messageProcessor.start();
writeThread.start();
readThread.start();
scheduler.schedule(
() -> sendLogin(token), LOGIN_FALLBACK_MILLIS,
TimeUnit.MILLISECONDS);
} catch (Exception exception) {
try {
socket.close();
} catch (IOException ignored) {
// The original connection exception is more useful.
}
scheduler.execute(() -> handleOpenFailure(token, exception));
}
}
private boolean mayOpen(long token) {
return !stopRequested && generation.get() == token;
}
private void sendLogin(long sessionId) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.loginSent
|| session.closed || stopRequested) {
return;
}
try {
ChatPreferences preferences = controller.getChatPreferences();
int mainCategory = preferences.getLoginChatCategoryMain()
.getCategoryNumber();
long historyFrom = Math.max(
0L, lastReceivedMessageTimestamp.get() - 1L);
String login = On4KstProtocol.login(
preferences.getStn_loginCallSign(),
preferences.getStn_loginPassword(),
mainCategory,
"KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION,
historyFrom);
session.loginSent = true;
session.lastProgressMillis.set(System.currentTimeMillis());
LOGGER.log(Level.INFO,
"Sending ON4KST login for session {0}, main category {1}",
new Object[] {sessionId, mainCategory});
transition(On4KstConnectionState.AUTHENTICATING,
"ON4KST login sent", false);
sendControl(session, login);
} catch (IllegalArgumentException invalidConfiguration) {
failPermanently(session,
"Invalid ON4KST login configuration: "
+ invalidConfiguration.getMessage());
}
}
private void handleLogstat(long sessionId, String[] fields) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.closed) {
return;
}
String code = fields.length > 1 ? fields[1] : "";
if (!"100".equals(code)) {
String serverText = fields.length > 2 ? fields[2] : "Login rejected";
failPermanently(session,
"ON4KST login rejected (" + code + "): " + serverText);
return;
}
if (session.authenticated) {
return;
}
session.authenticated = true;
session.lastProgressMillis.set(System.currentTimeMillis());
LOGGER.log(Level.INFO,
"ON4KST login accepted for session {0}", sessionId);
int mainCategory = controller.getChatPreferences()
.getLoginChatCategoryMain().getCategoryNumber();
transition(On4KstConnectionState.SYNCING_MAIN_CHAT,
"Login accepted; loading main chat", false);
sendControl(session, On4KstProtocol.settingsDone(mainCategory));
}
/**
* Publishes the initial user snapshot for one chat category exactly once.
*
* <p>ON4KST can send further {@code UE} frames after live user updates or
* after commands such as {@code SETNAME} and {@code BACK}. Those frames do
* not announce a new, empty snapshot. Treating them as another initial-list
* completion would remove the already published members because the staging
* map was consumed by the first {@code UE} frame.</p>
*
* <p>The completed-category set is updated before the staging map is removed.
* This makes the operation idempotent even if completion callbacks should
* later be invoked from more than one thread. A genuinely empty initial list
* remains valid: the first {@code UE} for a category is always processed,
* even when no preceding valid {@code UA0} frame was staged.</p>
*
* @param sessionId immutable id of the socket session that received the frame
* @param categoryNumber numeric ON4KST category terminated by {@code UE}
*/
private void completeInitialUserList(long sessionId, int categoryNumber) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.closed) {
return;
}
if (!session.completedInitialUserLists.add(categoryNumber)) {
LOGGER.log(Level.FINE,
"ON4KST session {0}: ignoring duplicate user-list end "
+ "marker for category {1}; the initial snapshot "
+ "has already been published",
new Object[] {sessionId, categoryNumber});
return;
}
Map<String, ChatMember> staged =
session.initialMembers.remove(categoryNumber);
Collection<ChatMember> completeMembers = staged == null
? List.of()
: new ArrayList<>(staged.values());
LOGGER.log(Level.INFO,
"ON4KST session {0}: complete user list for category {1} "
+ "contains {2} valid users",
new Object[] {
sessionId,
categoryNumber,
completeMembers.size()
});
controller.replaceActiveChatMembersForCategory(
sessionId,
new ChatCategory(categoryNumber),
completeMembers);
ChatPreferences preferences = controller.getChatPreferences();
int mainCategory =
preferences.getLoginChatCategoryMain().getCategoryNumber();
if (categoryNumber == mainCategory && !session.mainListComplete) {
session.mainListComplete = true;
configureMainChat(session);
if (hasDistinctSecondChat(preferences)) {
int secondCategory =
preferences.getLoginChatCategorySecond()
.getCategoryNumber();
transition(
On4KstConnectionState.SYNCING_SECOND_CHAT,
"Main chat ready; loading second chat",
false);
sendControl(
session,
On4KstProtocol.addChat(
secondCategory,
Math.max(
0L,
lastReceivedMessageTimestamp.get() - 1L)));
} else {
markOnline(session);
}
return;
}
if (hasDistinctSecondChat(preferences)
&& categoryNumber
== preferences.getLoginChatCategorySecond()
.getCategoryNumber()
&& !session.secondListComplete) {
session.secondListComplete = true;
configureSecondChat(session);
markOnline(session);
}
}
private void configureMainChat(Session session) {
ChatPreferences preferences = controller.getChatPreferences();
int category = preferences.getLoginChatCategoryMain().getCategoryNumber();
sendControl(session, On4KstProtocol.setLocator(
category, preferences.getStn_loginLocatorMainCat()));
if (preferences.getStn_loginNameMainCat() != null
&& !preferences.getStn_loginNameMainCat().isBlank()) {
sendControl(session, On4KstProtocol.setName(
category, preferences.getStn_loginNameMainCat()));
}
sendControl(session, On4KstProtocol.back(category));
String secondLocator = preferences.getStn_loginLocatorSecondCat();
String mainLocator = preferences.getStn_loginLocatorMainCat();
if (preferences.isLoginToSecondChatEnabled()
&& secondLocator != null && !secondLocator.isBlank()
&& !secondLocator.equalsIgnoreCase(mainLocator)) {
controller.onOn4KstConnectionWarning(
"ON4KST uses one locator per TCP session. The second-chat locator '"
+ secondLocator + "' is ignored; using '" + mainLocator + "'.");
}
}
private void configureSecondChat(Session session) {
ChatPreferences preferences = controller.getChatPreferences();
int category = preferences.getLoginChatCategorySecond().getCategoryNumber();
if (preferences.getStn_loginNameSecondCat() != null
&& !preferences.getStn_loginNameSecondCat().isBlank()) {
sendControl(session, On4KstProtocol.setName(
category, preferences.getStn_loginNameSecondCat()));
}
sendControl(session, On4KstProtocol.back(category));
}
private void markOnline(Session session) {
if (!isActiveSession(session.id)) {
return;
}
reconnectAttempt = 0;
session.online = true;
session.lastProgressMillis.set(System.currentTimeMillis());
LOGGER.log(Level.INFO,
"ON4KST session {0} is authenticated and synchronized",
session.id);
transition(On4KstConnectionState.ONLINE,
"ON4KST session is authenticated and synchronized", false);
controller.onOn4KstConnectionOnline();
}
private void sendControl(Session session, String frame) {
if (session == null || !isActiveSession(session.id)) {
return;
}
ChatMessage message = new ChatMessage();
message.setMessageDirectedToServer(true);
message.setMessageText(frame);
session.transmitQueue.offer(message);
}
private void sendHeartbeat(Session session) {
if (session == null || !isActiveSession(session.id)) {
return;
}
long now = System.currentTimeMillis();
session.lastHeartbeatMillis.set(now);
LOGGER.log(Level.FINE,
"Sending application heartbeat for ON4KST session {0}",
session.id);
ChatMessage heartbeat = new ChatMessage();
heartbeat.setMessageDirectedToServer(true);
heartbeat.setMessageText("");
session.transmitQueue.offer(heartbeat);
}
private void onConnectionFailure(long sessionId, Throwable failure) {
scheduler.execute(() -> failSession(sessionId, failure));
}
private void failSession(long sessionId, Throwable failure) {
Session failedSession;
synchronized (this) {
failedSession = activeSession;
if (failedSession == null || failedSession.id != sessionId
|| failedSession.closed) {
return;
}
activeSession = null;
failedSession.closed = true;
}
closeSession(failedSession);
controller.onOn4KstConnectionLost();
if (stopRequested) {
transition(On4KstConnectionState.DISCONNECTED,
"ON4KST connection stopped", false);
return;
}
scheduleReconnect(failure);
}
private void handleOpenFailure(long token, Throwable failure) {
if (!mayOpen(token)) {
return;
}
controller.onOn4KstConnectionLost();
scheduleReconnect(failure);
}
private void scheduleReconnect(Throwable failure) {
if (stopRequested) {
transition(On4KstConnectionState.DISCONNECTED,
"ON4KST connection stopped", false);
return;
}
String reason = describeFailure(failure);
LOGGER.log(Level.WARNING,
"ON4KST connection lost; automatic reconnect scheduled", failure);
long delay = RECONNECT_DELAYS_MILLIS.get(Math.min(
reconnectAttempt, RECONNECT_DELAYS_MILLIS.size() - 1));
reconnectAttempt++;
transition(On4KstConnectionState.RECONNECT_WAIT,
"Connection lost (" + reason + "); reconnecting in "
+ Duration.ofMillis(delay).toSeconds() + " s", true);
long nextToken = generation.incrementAndGet();
scheduler.schedule(() -> {
if (!mayOpen(nextToken)) {
return;
}
transition(On4KstConnectionState.CONNECTING,
"Reconnecting to ON4KST", false);
openConnection(nextToken);
}, delay, TimeUnit.MILLISECONDS);
}
private void failPermanently(Session session, String reason) {
if (session == null || !isActiveSession(session.id)) {
return;
}
stopRequested = true;
generation.incrementAndGet();
activeSession = null;
session.closed = true;
closeSession(session);
controller.onOn4KstConnectionLost();
LOGGER.log(Level.WARNING, reason);
transition(On4KstConnectionState.DISCONNECTED, reason, true);
}
private void monitorActiveSession() {
try {
Session session = activeSession;
if (session == null || session.closed || stopRequested) {
return;
}
if (session.socket.isClosed()) {
failSession(session.id,
new SocketException("Socket is closed"));
return;
}
long now = System.currentTimeMillis();
if (!session.online
&& now - session.lastProgressMillis.get()
> HANDSHAKE_TIMEOUT_MILLIS) {
failSession(session.id,
new SocketException("ON4KST handshake timed out"));
return;
}
long inboundIdle = now - session.lastInboundMillis.get();
if (inboundIdle > INBOUND_STALE_AFTER_MILLIS) {
failSession(session.id,
new SocketException("No ON4KST data received for "
+ inboundIdle / 1_000L + " seconds"));
return;
}
if (inboundIdle > APPLICATION_HEARTBEAT_AFTER_MILLIS
&& session.lastHeartbeatMillis.get()
< session.lastInboundMillis.get()) {
sendHeartbeat(session);
}
} catch (RuntimeException exception) {
LOGGER.log(Level.WARNING,
"ON4KST connection monitor failed", exception);
}
}
private void validateConfiguration() {
ChatPreferences preferences = controller.getChatPreferences();
On4KstProtocol.login(
preferences.getStn_loginCallSign(),
preferences.getStn_loginPassword(),
preferences.getLoginChatCategoryMain().getCategoryNumber(),
"KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION,
0L);
On4KstProtocol.locator(preferences.getStn_loginLocatorMainCat());
if (preferences.getStn_loginNameMainCat() != null
&& !preferences.getStn_loginNameMainCat().isBlank()) {
On4KstProtocol.field(
preferences.getStn_loginNameMainCat(), "main chat name");
}
if (preferences.isLoginToSecondChatEnabled()) {
if (preferences.getLoginChatCategorySecond() == null) {
throw new IllegalArgumentException("Second chat has no category");
}
On4KstProtocol.category(
preferences.getLoginChatCategorySecond().getCategoryNumber());
if (preferences.getLoginChatCategorySecond().getCategoryNumber()
== preferences.getLoginChatCategoryMain().getCategoryNumber()) {
controller.onOn4KstConnectionWarning(
"Second ON4KST chat equals the main chat and will not be added twice.");
}
if (preferences.getStn_loginNameSecondCat() != null
&& !preferences.getStn_loginNameSecondCat().isBlank()) {
On4KstProtocol.field(
preferences.getStn_loginNameSecondCat(), "second chat name");
}
}
}
private boolean hasDistinctSecondChat(ChatPreferences preferences) {
return preferences.isLoginToSecondChatEnabled()
&& preferences.getLoginChatCategorySecond() != null
&& preferences.getLoginChatCategorySecond().getCategoryNumber()
!= preferences.getLoginChatCategoryMain().getCategoryNumber();
}
private void configureSocket(Socket socket) throws IOException {
socket.setTcpNoDelay(true);
socket.setKeepAlive(true);
try {
socket.setOption(ExtendedSocketOptions.TCP_KEEPIDLE, 45);
socket.setOption(ExtendedSocketOptions.TCP_KEEPINTERVAL, 15);
socket.setOption(ExtendedSocketOptions.TCP_KEEPCOUNT, 3);
} catch (UnsupportedOperationException | IOException exception) {
LOGGER.log(Level.INFO,
"Platform does not support configurable TCP keepalive; "
+ "application heartbeat remains active", exception);
}
}
private void closeSession(Session session) {
if (session == null) {
return;
}
LOGGER.log(Level.FINE,
"Closing ON4KST session {0}", session.id);
session.closed = true;
if (session.readThread != null) {
session.readThread.interrupt();
}
if (session.writeThread != null) {
session.writeThread.interrupt();
}
if (session.messageProcessor != null) {
session.messageProcessor.interrupt();
}
try {
session.socket.close();
} catch (IOException exception) {
LOGGER.log(Level.FINE, "Error closing obsolete ON4KST socket", exception);
}
}
private void transition(
On4KstConnectionState newState,
String detail,
boolean critical
) {
On4KstConnectionState previousState = state;
state = newState;
Level level = critical
|| newState == On4KstConnectionState.DISCONNECTED
|| newState == On4KstConnectionState.RECONNECT_WAIT
? Level.WARNING : Level.INFO;
LOGGER.log(level,
"ON4KST state {0} -> {1}; detail: {2}",
new Object[] {previousState, newState, detail});
controller.updateOn4KstConnectionState(newState, detail, critical);
}
private void recordHistoryTimestamp(String line) {
long timestamp = parseMessageTimestamp(line);
if (timestamp > 0L) {
lastReceivedMessageTimestamp.accumulateAndGet(timestamp, Math::max);
}
}
static long parseMessageTimestamp(String line) {
String[] fields = line == null ? new String[0] : line.split("\\|", -1);
if (fields.length < 3) {
return 0L;
}
try {
long numeric = Long.parseLong(fields[2]);
long now = System.currentTimeMillis() / 1_000L;
if (numeric > 0L && numeric <= now + 86_400L) {
return numeric;
}
} catch (NumberFormatException ignored) {
return 0L;
}
try {
return LocalDateTime.parse(fields[2], LIVE_MESSAGE_TIMESTAMP)
.toEpochSecond(ZoneOffset.UTC);
} catch (DateTimeParseException ignored) {
return 0L;
}
}
private String opcode(String line) {
if (line == null) {
return "";
}
int separator = line.indexOf('|');
return (separator < 0 ? line : line.substring(0, separator))
.trim().toUpperCase(Locale.ROOT);
}
private String describeFailure(Throwable failure) {
if (failure == null) {
return "unknown error";
}
String message = failure.getMessage();
return message == null || message.isBlank()
? failure.getClass().getSimpleName() : message;
}
private static final class Session {
private final Set<Integer> completedInitialUserLists =
ConcurrentHashMap.newKeySet();
private final long id;
private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LinkedBlockingQueue<ChatMessage> transmitQueue;
private final long connectedMillis = System.currentTimeMillis();
private final AtomicLong lastInboundMillis =
new AtomicLong(connectedMillis);
private final AtomicLong lastProgressMillis =
new AtomicLong(connectedMillis);
private final AtomicLong lastHeartbeatMillis = new AtomicLong();
private final Map<Integer, Map<String, ChatMember>> initialMembers =
new ConcurrentHashMap<>();
private volatile ReadThread readThread;
private volatile WriteThread writeThread;
private volatile MessageBusManagementThread messageProcessor;
private volatile boolean loginSent;
private volatile boolean authenticated;
private volatile boolean mainListComplete;
private volatile boolean secondListComplete;
private volatile boolean online;
private volatile boolean closed;
private Session(
long id,
Socket socket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LinkedBlockingQueue<ChatMessage> transmitQueue
) {
this.id = id;
this.socket = socket;
this.receiveQueue = receiveQueue;
this.transmitQueue = transmitQueue;
}
}
}
@@ -0,0 +1,50 @@
package kst4contest.controller;
/**
* Observable lifecycle of the ON4KST TCP session.
*
* <p>A connected TCP socket is deliberately not synonymous with an authenticated
* chat session. The intermediate states make that distinction visible to the UI
* and prevent application messages from being sent in the wrong protocol context.</p>
*/
public enum On4KstConnectionState {
DISCONNECTED,
CONNECTING,
WAITING_FOR_LOGIN_PROMPT,
AUTHENTICATING,
SYNCING_MAIN_CHAT,
SYNCING_SECOND_CHAT,
ONLINE,
RECONNECT_WAIT,
STOPPING;
/**
* Returns whether the complete application-level ON4KST handshake has finished.
*
* @return {@code true} only after authentication and all requested user lists
* have been synchronized
*/
public boolean isOnline() {
return this == ONLINE;
}
/**
* Returns whether a connection attempt or usable session is currently owned by
* the connection manager.
*
* <p>This is intentionally broader than {@link #isOnline()}. The UI uses it to
* prevent a second Connect action while authentication, synchronization or a
* scheduled reconnect is already in progress.</p>
*
* @return {@code true} while connecting, synchronizing, online or waiting for
* an automatic reconnect
*/
public boolean isConnectionAttemptActive() {
return switch (this) {
case CONNECTING, WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING,
SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT, ONLINE,
RECONNECT_WAIT -> true;
default -> false;
};
}
}
@@ -0,0 +1,192 @@
package kst4contest.controller;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Builds ON4KST port-23001 frames and rejects values that could break framing or
* put the server into an invalid chat context.
*
* <p>All outbound protocol construction is concentrated here. User-controlled
* values may therefore never introduce a field separator or a second line, and
* category and locator validation happens before the frame reaches the socket.</p>
*/
final class On4KstProtocol {
private static final Pattern LOCATOR_6 =
Pattern.compile("^[A-Ra-r]{2}[0-9]{2}[A-Xa-x]{2}$");
private On4KstProtocol() {
}
/**
* Builds the initial authenticated login frame.
*
* @param callsign login callsign
* @param password ON4KST password; never logged by this class
* @param category primary chat category
* @param clientName client identification sent to the server
* @param lastMessageTimestamp earliest history timestamp to request
* @return validated frame without CR/LF terminator
*/
static String login(
String callsign,
String password,
int category,
String clientName,
long lastMessageTimestamp
) {
return "LOGINC|" + field(callsign, "callsign")
+ "|" + password(password)
+ "|" + category(category)
+ "|" + field(clientName, "client name")
+ "|25|0|1|" + Math.max(0L, lastMessageTimestamp) + "|0|";
}
/** Builds the settings-complete frame for the supplied chat category. */
static String settingsDone(int category) {
return "SDONE|" + category(category) + "|";
}
/** Builds the frame used to add a distinct second chat to the same session. */
static String addChat(int category, long lastMessageTimestamp) {
return "ACHAT|" + category(category)
+ "|25|10|2|" + Math.max(0L, lastMessageTimestamp)
+ "|0|";
}
/** Builds a category-qualified locator command after validating Maidenhead syntax. */
static String setLocator(int category, String locator) {
return command(category, "/SETLOC " + locator(locator));
}
/** Builds a category-qualified chat-name command. */
static String setName(int category, String name) {
return command(category, "/SETNAME " + field(name, "chat name"));
}
/** Builds the command that changes the operator state back to available. */
static String back(int category) {
return command(category, "/BACK");
}
/**
* Wraps one validated slash command in an ON4KST message frame.
*
* @return frame without CR/LF terminator
*/
static String command(int category, String command) {
return "MSG|" + category(category) + "|0|"
+ messageText(command) + "|0|";
}
/**
* Wraps one operator chat message in a category-qualified ON4KST frame.
*
* @return frame without CR/LF terminator
*/
static String chatMessage(int category, String text) {
return "MSG|" + category(category) + "|0|"
+ messageText(text) + "|0|";
}
/**
* Removes trailing line terminators from a legacy raw frame while rejecting an
* embedded line break that could inject a second server command.
*
* @param frame legacy raw frame, possibly with trailing CR/LF
* @return exactly one normalized protocol line
* @throws IllegalArgumentException if the value is {@code null} or contains an
* embedded line break
*/
static String normalizeRawFrame(String frame) {
if (frame == null) {
throw new IllegalArgumentException("ON4KST frame must not be null");
}
int end = frame.length();
while (end > 0) {
char last = frame.charAt(end - 1);
if (last != '\r' && last != '\n') {
break;
}
end--;
}
String normalized = frame.substring(0, end);
if (normalized.indexOf('\r') >= 0 || normalized.indexOf('\n') >= 0) {
throw new IllegalArgumentException(
"ON4KST frame contains an embedded line break");
}
return normalized;
}
/**
* Validates and normalizes a six-character Maidenhead locator.
*
* @return upper-case locator
*/
static String locator(String locator) {
String normalized = field(locator, "locator").toUpperCase(Locale.ROOT);
if (!LOCATOR_6.matcher(normalized).matches()) {
throw new IllegalArgumentException(
"Locator must be a six-character Maidenhead locator: " + normalized);
}
return normalized;
}
/** Rejects message text containing an ON4KST field or line delimiter. */
static String messageText(String text) {
String value = field(text, "message text");
if (value.indexOf('|') >= 0) {
throw new IllegalArgumentException(
"Message text contains the ON4KST field separator '|'");
}
return value;
}
/**
* Validates one required, non-password protocol field.
*
* @param value field value
* @param label diagnostic label used in validation errors
* @return trimmed value
*/
static String field(String value, String label) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(label + " must not be empty");
}
if (value.indexOf('|') >= 0
|| value.indexOf('\r') >= 0
|| value.indexOf('\n') >= 0) {
throw new IllegalArgumentException(
label + " contains an ON4KST frame delimiter");
}
return value.trim();
}
private static String password(String value) {
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException("password must not be empty");
}
if (value.indexOf('|') >= 0
|| value.indexOf('\r') >= 0
|| value.indexOf('\n') >= 0) {
throw new IllegalArgumentException(
"password contains an ON4KST frame delimiter");
}
return value;
}
/**
* Validates the category range supported by ON4KST.
*
* @return the unchanged category for convenient inline use
*/
static int category(int category) {
if (category < 1 || category > 12) {
throw new IllegalArgumentException(
"Unsupported ON4KST chat category: " + category);
}
return category;
}
}
@@ -0,0 +1,69 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
class On4KstProtocolTest {
@Test
void buildsLoginWithReplayOverlap() {
assertEquals(
"LOGINC|DL1ABC|secret|2|KST4Contest v1.2.3|25|0|1|12344|0|",
On4KstProtocol.login(
"DL1ABC", "secret", 2,
"KST4Contest v1.2.3", 12_344L));
}
@Test
void buildsContextSafeSecondChatFrames() {
assertEquals("SDONE|2|", On4KstProtocol.settingsDone(2));
assertEquals("ACHAT|3|25|10|2|100|0|",
On4KstProtocol.addChat(3, 100L));
assertEquals("MSG|2|0|/SETLOC JO31AA|0|",
On4KstProtocol.setLocator(2, "jo31aa"));
assertEquals("MSG|3|0|/SETNAME 10G 10368.200|0|",
On4KstProtocol.setName(3, "10G 10368.200"));
}
@Test
void stripsOnlyTrailingLineEndings() {
assertEquals("CK|", On4KstProtocol.normalizeRawFrame("CK|\r\n"));
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.normalizeRawFrame("CK|\rBROKEN"));
}
@Test
void rejectsValuesThatCouldCreateASecondProtocolFrame() {
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.chatMessage(2, "hello|0|"));
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.chatMessage(2, "hello\r\nQUIT|"));
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.login(
"DL1ABC", "bad|password", 2, "client", 0L));
}
@Test
void rejectsInvalidLocatorAndCategoryBeforeTheyReachTheServer() {
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.setLocator(2, "JO31"));
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.settingsDone(99));
}
@Test
void convertsBothHistoryAndLiveMessageTimestampsForReconnect() {
assertEquals(1_186_819_108L,
On4KstConnectionManager.parseMessageTimestamp(
"CR|2|1186819108|EA6VQ|Gabriel|0|msg|0|"));
assertEquals(
LocalDateTime.of(2026, 8, 13, 12, 34, 56)
.toEpochSecond(ZoneOffset.UTC),
On4KstConnectionManager.parseMessageTimestamp(
"CH|2|20260813123456|DL1ABC|Op|0|msg|0|"));
}
}
@@ -0,0 +1,100 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.BufferedReader;
import java.io.OutputStreamWriter;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
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 kst4contest.model.ChatMessage;
class On4KstSocketThreadTest {
@Test
@Timeout(5)
void eofIsReportedImmediately() throws Exception {
try (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("CK|\r\n");
out.flush();
} catch (Exception 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);
CompletableFuture<Throwable> failure = new CompletableFuture<>();
ReadThread reader = new ReadThread(
7L, client, queue, ignored -> active.get(), ignored -> { },
failure::complete);
reader.start();
assertEquals("CK|", queue.poll(2, TimeUnit.SECONDS).getMessageText());
failure.get(2, TimeUnit.SECONDS);
active.set(false);
reader.join(Duration.ofSeconds(2).toMillis());
}
serverDone.get(2, TimeUnit.SECONDS);
}
}
@Test
@Timeout(5)
void writerUsesOneExactCrLfPerFrameIncludingHeartbeat() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<String> firstLine = new CompletableFuture<>();
CompletableFuture<String> secondLine = new CompletableFuture<>();
CompletableFuture<Void> serverDone = CompletableFuture.runAsync(() -> {
try (Socket accepted = server.accept();
BufferedReader in = new BufferedReader(new InputStreamReader(
accepted.getInputStream(), StandardCharsets.UTF_8))) {
firstLine.complete(in.readLine());
secondLine.complete(in.readLine());
} catch (Exception 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(
11L, client, queue, 2, ignored -> active.get(),
ignored -> { }, ignored -> { });
writer.start();
queue.add(serverFrame(""));
queue.add(serverFrame("SDONE|2|\r"));
assertEquals("", firstLine.get(2, TimeUnit.SECONDS));
assertEquals("SDONE|2|", secondLine.get(2, TimeUnit.SECONDS));
active.set(false);
writer.interrupt();
writer.join(Duration.ofSeconds(2).toMillis());
}
serverDone.get(2, TimeUnit.SECONDS);
}
}
private ChatMessage serverFrame(String text) {
ChatMessage message = new ChatMessage();
message.setMessageDirectedToServer(true);
message.setMessageText(text);
return message;
}
}
@@ -1,119 +1,130 @@
package kst4contest.controller;
import java.io.*;
import java.net.*;
import java.io.BufferedReader;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.Consumer;
import java.util.function.LongPredicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.model.ChatMessage;
/**
* This thread is responsible for reading telnet servers input at port 23001 and printing it
* to the console.
* It runs in an infinite loop until the client disconnects from the server.
* Reads exactly one immutable ON4KST connection session.
*
* @author www.codejava.net
* <p>EOF is a connection-loss event, not an empty chat message. Every line is
* associated with the session id captured by this reader, so a delayed exception
* from an obsolete socket cannot affect a newer reconnect.</p>
*/
public class ReadThread extends Thread {
private static final Logger LOGGER = Logger.getLogger(ReadThread.class.getName());
private BufferedReader reader;
private Socket socket;
private ChatController client;
public boolean accidentalDisconnected;
public boolean isAccidentalDisconnected() {
return accidentalDisconnected;
}
public void setAccidentalDisconnected(boolean accidentalDisconnected) {
this.accidentalDisconnected = accidentalDisconnected;
}
private final long sessionId;
private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LongPredicate sessionIsActive;
private final Consumer<String> inboundActivity;
private final Consumer<Throwable> connectionFailure;
private final BufferedReader reader;
// private boolean readingFinished = true; //kst4contest.test 4 23001
private boolean readingFinished = true;
InputStream input;
public ReadThread(Socket socket, ChatController client) {
/**
* Compatibility constructor for the pre-session controller path.
*
* @deprecated new connections should be created by
* {@link On4KstConnectionManager}
*/
@Deprecated
public ReadThread(Socket socket, ChatController client) throws IOException {
this(0L, socket, client.getMessageRXBus(), ignored -> true,
ignored -> { }, ignored -> { });
}
/**
* Creates the reader for one connection generation.
*
* @param sessionId immutable id of the owning socket session
* @param socket connected ON4KST socket
* @param receiveQueue private receive queue belonging to this session
* @param sessionIsActive guard against callbacks from an obsolete session
* @param inboundActivity callback used for liveness and protocol progress
* @param connectionFailure callback for EOF, I/O and unexpected runtime errors
* @throws IOException if the socket input stream cannot be opened
*/
public ReadThread(
long sessionId,
Socket socket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LongPredicate sessionIsActive,
Consumer<String> inboundActivity,
Consumer<Throwable> connectionFailure
) throws IOException {
this.sessionId = sessionId;
this.socket = socket;
this.client = client;
try {
input = socket.getInputStream();
reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
} catch (IOException ex) {
LOGGER.log(Level.SEVERE, "Error getting input stream", ex);
}
this.receiveQueue = receiveQueue;
this.sessionIsActive = sessionIsActive;
this.inboundActivity = inboundActivity;
this.connectionFailure = connectionFailure;
this.reader = new BufferedReader(new InputStreamReader(
socket.getInputStream(), StandardCharsets.UTF_8));
}
@Override
public void run() {
Thread.currentThread().setName("ReadFromTelnetThread");
ChatMessage message; //bugfix leak, moved out of while
while (true) {
// System.out.println("rdth");
try {
String response = reader.readLine();
message = new ChatMessage();
message.setMessageText(response);
// message.setDirectedToServer(false);
// message.setDirectedToServer(false);
// message.setDirectedToServer(false);
if (response != null) {
client.getMessageRXBus().put(message);
// System.out.println("[RT]: read message and added it to msgrxqueue --- " + response + " ---");
} else {
System.out.println("[RT]: read message responsed a nullstring, do nothing, buffersize = " + socket.getReceiveBufferSize() + ", reader ready? "
+ reader.ready());
// reader = new BufferedReader(new InputStreamReader(input));
// response = reader.readLine();
this.client.getSocket().close();
this.interrupt();
Thread.currentThread().setName("ReadFromOn4Kst-" + sessionId);
LOGGER.log(Level.FINE,
"ON4KST reader started for session {0}", sessionId);
try {
while (!isInterrupted() && sessionIsActive.test(sessionId)) {
String response = reader.readLine();
if (response == null) {
throw new EOFException("ON4KST closed the TCP connection");
}
}
catch (Exception sexc) {
LOGGER.log(Level.SEVERE, "[ReadThread] Socket closed unexpectedly", sexc);
try {
this.client.getSocket().close();
this.interrupt();
break;
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "[ReadThread] Error closing socket", e);
}
inboundActivity.accept(response);
if (!sessionIsActive.test(sessionId)) {
break;
}
ChatMessage message = new ChatMessage();
message.setMessageText(response);
receiveQueue.put(message);
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (IOException exception) {
if (sessionIsActive.test(sessionId)) {
LOGGER.log(Level.FINE,
"ON4KST read failed for session " + sessionId,
exception);
connectionFailure.accept(exception);
}
} catch (RuntimeException exception) {
if (sessionIsActive.test(sessionId)) {
LOGGER.log(Level.SEVERE, "Unexpected ON4KST reader failure", exception);
connectionFailure.accept(exception);
}
} finally {
LOGGER.log(Level.FINE,
"ON4KST reader stopped for session {0}", sessionId);
}
}
/**
* Interrupts the read loop and closes the session socket.
*
* @return always {@code true} after a successful close
* @throws IOException if closing the reader or socket fails
*/
public boolean terminateConnection() throws IOException {
this.reader.close();
this.input.close();
this.socket.close();
return true;
interrupt();
reader.close();
socket.close();
return true;
}
public boolean isReadingFinished() {
return readingFinished;
}
public void setReadingFinished(boolean readingReady) {
this.readingFinished = readingReady;
}
}
@@ -17,4 +17,20 @@ public interface StatusUpdateListener {
void onUserListUpdated(String reason);
// new: userlist-update
}
/**
* Called whenever the authoritative ON4KST session changes lifecycle state.
*
* <p>The callback may originate from a background connection supervisor. A UI
* implementation must marshal control changes onto its application thread.</p>
*
* @param state new connection, authentication or synchronization state
* @param detail human-readable progress or failure reason
*/
default void onConnectionStateChanged(
On4KstConnectionState state,
String detail
) {
// Optional for non-UI listeners.
}
}
@@ -1,289 +1,173 @@
package kst4contest.controller;
import java.io.*;
import java.net.*;
import java.nio.charset.Charset;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.Consumer;
import java.util.function.LongPredicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
/**
* This thread is responsible for sending content to the chat. As we only use
* the tx function, there is no content in run() method
*
* Serializes and writes exactly one immutable ON4KST connection session.
*
* <p>The writer owns one private queue and appends exactly one CR/LF terminator
* per frame. All category selection and delimiter validation happens before bytes
* reach the socket.</p>
*/
public class WriteThread extends Thread {
private PrintWriter writer;
private Socket socket;
private ChatController client;
private OutputStream output;
private ChatMessage messageToBeSend;
public WriteThread(Socket socket, ChatController client) throws InterruptedException {
this.socket = socket;
this.client = client;
try {
output = socket.getOutputStream();
writer = new PrintWriter(output, true, StandardCharsets.UTF_8);
} catch (IOException ex) {
System.out.println("Error getting output stream: " + ex.getMessage());
ex.printStackTrace();
}
}
private static final Logger LOGGER =
Logger.getLogger(WriteThread.class.getName());
private final long sessionId;
private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> transmitQueue;
private final LongPredicate sessionIsActive;
private final Consumer<Throwable> connectionFailure;
private final Consumer<String> rejectedFrame;
private final BufferedWriter writer;
private final int defaultCategory;
/**
* This method is used to send a message to the server, raw formatted. E.g. for
* the keepalive message. This method sends only in the main message-Category. To send it in a category
* "defined by Chatmessage", use txByRxmsgCatOrigin(Chatmessage "toBeSend")
*
* @param messageToServer
* @throws InterruptedException
*/
public void tx(ChatMessage messageToServer) throws InterruptedException {
// writer.println(messageToServer.getMessage()); //kst4contest.test 4 23001
// writer.flush(); //kst4contest.test 4 23001
System.out.println(messageToServer.getMessageText() + "< sended to the writer");
writer.println(messageToServer.getMessageText());
}
/**
* This method is used to send a message directly to a receiver in a special chatcategory. The receivers category
* will be read out of the Chatmessage.getChatCategory method. <b> The message text will be modified to fit kst
* messageformat</b>
* Compatibility constructor for the pre-session controller path.
*
* @param messageToServer
* @throws InterruptedException
* @deprecated new connections should be created by
* {@link On4KstConnectionManager}
*/
public void txByRxmsgCatOrigin(ChatMessage messageToServer) throws InterruptedException {
// writer.println(messageToServer.getMessage()); //kst4contest.test 4 23001
// writer.flush(); //kst4contest.test 4 23001
String originalMessageText = messageToServer.getMessageText() + "";
String newMessageText = "";
newMessageText = ("MSG|" + messageToServer.getChatCategory().getCategoryNumber()
+ "|0|" + originalMessageText + "|0|"); //original before 1.26
System.out.println(newMessageText + "< sended to the writer (DIRECTED REPLY)");
writer.println(newMessageText);
@Deprecated
public WriteThread(Socket socket, ChatController client) throws IOException {
this(0L, socket, client.getMessageTXBus(),
client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber(),
ignored -> true,
ignored -> { }, System.out::println);
}
/**
* This method gets a textmessage to the chat and adds some characters to hit
* the neccessarry format to send a message in the on4kst chat either to another
* station or to the public.
*
* @param messageToServer
* @throws InterruptedException
* Creates the writer for one connection generation.
*
* @param sessionId immutable id of the owning socket session
* @param socket connected ON4KST socket
* @param transmitQueue private transmit queue belonging to this session
* @param defaultCategory fallback category for unqualified chat messages
* @param sessionIsActive guard against writes from an obsolete session
* @param connectionFailure callback for socket and unexpected runtime errors
* @param rejectedFrame callback for locally rejected protocol content
* @throws IOException if the socket output stream cannot be opened
*/
public void txKSTFormatted(ChatMessage messageToServer) throws InterruptedException {
public WriteThread(
long sessionId,
Socket socket,
LinkedBlockingQueue<ChatMessage> transmitQueue,
int defaultCategory,
LongPredicate sessionIsActive,
Consumer<Throwable> connectionFailure,
Consumer<String> rejectedFrame
) throws IOException {
this.sessionId = sessionId;
this.socket = socket;
this.transmitQueue = transmitQueue;
this.defaultCategory = defaultCategory;
this.sessionIsActive = sessionIsActive;
this.connectionFailure = connectionFailure;
this.rejectedFrame = rejectedFrame;
this.writer = new BufferedWriter(new OutputStreamWriter(
socket.getOutputStream(), StandardCharsets.UTF_8));
}
// writer.println(messageToServer.getMessageText());
messageToBeSend = messageToServer;
@Override
public void run() {
Thread.currentThread().setName("WriteToOn4Kst-" + sessionId);
LOGGER.log(Level.FINE,
"ON4KST writer started for session {0}", sessionId);
try {
messageToBeSend = client.getMessageTXBus().take();
// this.client.getmesetChatsetServerready(true);
} catch (InterruptedException e) {
e.printStackTrace();
}
String messageLine = messageToBeSend.getMessageText();
if (messageToBeSend.isMessageDirectedToServer()) {
/**
* We have to check if we only commands the server (keepalive) or want do talk
* to the community
*/
try {
tx(messageToBeSend);
System.out.println("BUS: tx: " + messageToBeSend.getMessageText());
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
} else {
ChatMessage ownMSG = new ChatMessage();
// ownMSG.setMessageText(
// "MSG|" + this.client.getCategory().getCategoryNumber() + "|0|" + messageLine + "|0|");
ownMSG.setMessageText("MSG|" + this.client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber()
+ "|0|" + messageLine + "|0|"); //original before 1.26
try {
tx(ownMSG);
System.out.println("BUS: tx: " + ownMSG.getMessageText());
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
if (messageToBeSend.equals("/QUIT")) {
try {
this.client.getReadThread().terminateConnection();
this.client.getReadThread().interrupt();
this.client.getWriteThread().terminateConnection();
this.client.getWriteThread().interrupt();
this.interrupt();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public boolean terminateConnection() throws IOException {
this.output.close();
this.socket.close();
return true;
}
public void run() {
Thread.currentThread().setName("WriteToTelnetThread");
while (true) {
try {
messageToBeSend = client.getMessageTXBus().take();
if (messageToBeSend.getMessageText().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)
&& messageToBeSend.getMessageSenderName().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
client.getMessageRXBus().clear();
this.interrupt();
while (!isInterrupted() && sessionIsActive.test(sessionId)) {
ChatMessage message = transmitQueue.take();
if (isPoisonPill(message)) {
break;
}
if (!sessionIsActive.test(sessionId)) {
break;
} else {
String messageLine = messageToBeSend.getMessageText();
if (messageToBeSend.isMessageDirectedToServer()) {
/**
* We have to check if we only commands the server (keepalive) or want do talk
* to the community
*/
try {
tx(messageToBeSend);
System.out.println("BUS: tx: " + messageToBeSend.getMessageText());
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
} else { //message is not directed to the server, it´s directed to all or to a station
if (messageToBeSend.getChatCategory() == this.client.getChatCategoryMain() || messageToBeSend.getChatCategory() == this.client.getChatCategorySecondChat()) {
txByRxmsgCatOrigin(messageToBeSend);
} else { //default bhv if destination cat is not detectable
ChatMessage ownMSG = new ChatMessage();
ownMSG.setMessageText(
"MSG|" + this.client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber() + "|0|"
+ messageLine + "|0|");
try {
tx(ownMSG);
System.out.println("WT: tx (raw): " + ownMSG.getMessageText());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
System.out.println("WritheTh: got message out of the queue: " + messageToBeSend.getMessageText());
// this.client.getmesetChatsetServerready(true);
} catch (InterruptedException e) {
e.printStackTrace();
client.getMessageTXBus().clear();
}
// String messageLine = messageTextRaw.getMessageText();
//
// if (messageTextRaw.isMessageDirectedToServer()) {
// /**
// * We have to check if we only commands the server (keepalive) or want do talk
// * to the community
// */
//
// try {
// tx(messageTextRaw);
// System.out.println("BUS: tx: " + messageTextRaw.getMessageText());
//
// } catch (InterruptedException e) {
// // TODO Auto-generated catch block
// e.printStackTrace();
// }
//
// } else {
//
// ChatMessage ownMSG = new ChatMessage();
//
//// ownMSG.setMessageText(
//// "MSG|" + this.client.getCategory().getCategoryNumber() + "|0|" + messageLine + "|0|");
//
// ownMSG.setMessageText(
// "MSG|" + this.client.getChatPreferences().getLoginChatCategory().getCategoryNumber() + "|0|"
// + messageLine + "|0|");
//
// try {
// tx(ownMSG);
// System.out.println("BUS: tx: " + ownMSG.getMessageText());
//
// } catch (InterruptedException e) {
// // TODO Auto-generated catch block
// e.printStackTrace();
// }
// }
try {
writeFrame(formatFrame(message));
} catch (IllegalArgumentException invalidFrame) {
LOGGER.log(Level.FINE,
"Rejected outbound ON4KST frame in session {0}: {1}",
new Object[] {sessionId, invalidFrame.getMessage()});
rejectedFrame.accept(invalidFrame.getMessage());
}
}
// if (messageTextRaw.equals("/QUIT")) {
// try {
// this.client.getReadThread().terminateConnection();
// this.client.getReadThread().interrupt();
// this.client.getWriteThread().terminateConnection();
// this.client.getWriteThread().interrupt();
// this.interrupt();
//
// } catch (IOException e) {
// // TODO Auto-generated catch block
// e.printStackTrace();
// }
// }
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (IOException exception) {
if (sessionIsActive.test(sessionId)) {
LOGGER.log(Level.FINE,
"ON4KST write failed for session " + sessionId,
exception);
connectionFailure.accept(exception);
}
} catch (RuntimeException exception) {
if (sessionIsActive.test(sessionId)) {
LOGGER.log(Level.SEVERE,
"Unexpected ON4KST writer failure for session "
+ sessionId,
exception);
connectionFailure.accept(exception);
}
} finally {
LOGGER.log(Level.FINE,
"ON4KST writer stopped for session {0}", sessionId);
}
}
// while (true) {
//
// }
private String formatFrame(ChatMessage message) {
if (message == null) {
throw new IllegalArgumentException("Cannot send an empty ON4KST message");
}
if (message.isMessageDirectedToServer()) {
return On4KstProtocol.normalizeRawFrame(message.getMessageText());
}
ChatCategory category = message.getChatCategory();
return On4KstProtocol.chatMessage(
category == null ? defaultCategory : category.getCategoryNumber(),
message.getMessageText());
}
private void writeFrame(String frame) throws IOException {
writer.write(frame);
writer.write("\r\n");
writer.flush();
}
private boolean isPoisonPill(ChatMessage message) {
return message != null
&& ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(
message.getMessageText())
&& ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(
message.getMessageSenderName());
}
/**
* Interrupts the write loop and closes the session socket.
*
* @return always {@code true} after a successful close
* @throws IOException if closing the writer or socket fails
*/
public boolean terminateConnection() throws IOException {
interrupt();
writer.close();
socket.close();
return true;
}
}
@@ -4,6 +4,13 @@ import java.util.TimerTask;
import kst4contest.model.ChatMessage;
/**
* Enqueues the empty application-level reply expected by ON4KST keepalive
* handling.
*
* <p>The writer owns line termination and appends exactly one CR/LF sequence.
* Supplying an empty payload here avoids the historic double-terminator frame.</p>
*/
public class keepAliveMessageSenderTask extends TimerTask {
private ChatController client;
@@ -16,13 +23,14 @@ public class keepAliveMessageSenderTask extends TimerTask {
@Override
public void run() {
Thread.currentThread().setName("KeepAliveMessageSenderTask");
// System.out.println("[keepalive: ] Thread runned now");
ChatMessage keepAliveMSG = new ChatMessage();
keepAliveMSG.setMessageText("\r");
// WriteThread appends exactly one CRLF. An empty frame is the protocol reply.
keepAliveMSG.setMessageText("");
keepAliveMSG.setMessageDirectedToServer(true);
// System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() + " [keepaliveTask]: Sending keepalive: "
@@ -33,4 +41,4 @@ public class keepAliveMessageSenderTask extends TimerTask {
this.client.getMessageTXBus().add(keepAliveMSG);
}
}
}
@@ -1,5 +1,6 @@
package kst4contest.view;
import kst4contest.utils.VersionUtils;
import kst4contest.controller.On4KstConnectionState;
import javafx.scene.image.Image;
import java.io.File;
@@ -81,6 +82,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// Enables optional color highlighting of the QRA/grid cell.
// The status text itself remains visible independently of this flag.
private static final Logger LOGGER = Logger.getLogger(
Kst4ContestApplication.class.getName());
private boolean gridSquareHighlightEnabled = false;
@@ -113,6 +118,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
private StationMapView stationMapView; //view class for the avl stn map
private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view
private final Button btnConnectionStateIndicator = new Button("LINK");
private final Tooltip tipConnectionStateIndicator = new Tooltip();
private On4KstConnectionState lastDisplayedConnectionState;
private String lastDisplayedConnectionDetail = "";
private final Button btnBandUpgradeIndicator = new Button("BAND+");
private final Tooltip tipBandUpgradeIndicator = new Tooltip();
private Timeline bandUpgradeBlinkTimeline;
@@ -5320,8 +5330,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
menuItemOptionsAwayBack.setDisable(false);
menuItemOptionsSetFrequencyAsName.setDisable(false);
chatcontroller.setConnectedAndLoggedIn(true);
chatcontroller.setDisconnected(false);
// chatcontroller.setConnectedAndLoggedIn(true);
// chatcontroller.setDisconnected(false);
} catch (InterruptedException | IOException e) {
e.printStackTrace();
@@ -5643,6 +5653,121 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
btnSkedWarnIndicator.setTooltip(tipSkedWarnIndicator);
}
private void initConnectionStateIndicatorButton() {
LOGGER.fine("Initializing compact ON4KST connection-state indicator");
btnConnectionStateIndicator.setMouseTransparent(true);
btnConnectionStateIndicator.setFocusTraversable(false);
btnConnectionStateIndicator.setMnemonicParsing(false);
btnConnectionStateIndicator.setMinSize(44, 22);
btnConnectionStateIndicator.setPrefSize(44, 22);
btnConnectionStateIndicator.setMaxSize(44, 22);
btnConnectionStateIndicator.setTooltip(tipConnectionStateIndicator);
btnConnectionStateIndicator.setAccessibleText(
"ON4KST connection state");
FlowPane.setMargin(
btnConnectionStateIndicator, new Insets(2, 4, 2, 4));
updateConnectionStateIndicator(
chatcontroller.getOn4KstConnectionState(),
"No ON4KST connection");
}
private void updateConnectionStateIndicator(
On4KstConnectionState state,
String detail
) {
if (!Platform.isFxApplicationThread()) {
LOGGER.warning(
"Connection indicator update arrived outside the JavaFX thread; "
+ "rescheduling it safely");
Platform.runLater(() -> updateConnectionStateIndicator(state, detail));
return;
}
On4KstConnectionState effectiveState = state == null
? On4KstConnectionState.DISCONNECTED : state;
String stateDetail = detail == null || detail.isBlank()
? effectiveState.name() : detail;
logConnectionIndicatorTransition(effectiveState, stateDetail);
tipConnectionStateIndicator.setText(
"ON4KST link: " + effectiveState.name() + "\n" + stateDetail);
btnConnectionStateIndicator.setAccessibleHelp(stateDetail);
String commonStyle =
"-fx-font-size: 10px;"
+ "-fx-font-weight: bold;"
+ "-fx-padding: 1 5 1 5;"
+ "-fx-background-radius: 6;"
+ "-fx-border-radius: 6;"
+ "-fx-border-width: 2;";
switch (effectiveState) {
case ONLINE -> {
btnConnectionStateIndicator.setText("LINK");
btnConnectionStateIndicator.setStyle(
commonStyle
+ "-fx-background-color: #238636;"
+ "-fx-border-color: #56d364;"
+ "-fx-text-fill: white;"
+ "-fx-effect: dropshadow(three-pass-box, "
+ "rgba(35,134,54,0.55), 5, 0.25, 0, 0);");
}
case CONNECTING, WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING,
SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT, STOPPING -> {
btnConnectionStateIndicator.setText("LINK…");
btnConnectionStateIndicator.setStyle(
commonStyle
+ "-fx-background-color: #ffb300;"
+ "-fx-border-color: #ffe082;"
+ "-fx-text-fill: #1b1b1b;"
+ "-fx-effect: dropshadow(three-pass-box, "
+ "rgba(255,179,0,0.65), 6, 0.3, 0, 0);");
}
case DISCONNECTED, RECONNECT_WAIT -> {
btnConnectionStateIndicator.setText("LINK!");
btnConnectionStateIndicator.setStyle(
commonStyle
+ "-fx-background-color: #d50000;"
+ "-fx-border-color: #ff6b6b;"
+ "-fx-text-fill: white;"
+ "-fx-effect: dropshadow(three-pass-box, "
+ "rgba(255,0,0,0.95), 10, 0.55, 0, 0);");
}
}
}
private void logConnectionIndicatorTransition(
On4KstConnectionState newState,
String newDetail
) {
boolean stateChanged = newState != lastDisplayedConnectionState;
boolean detailChanged = !Objects.equals(
newDetail, lastDisplayedConnectionDetail);
if (!stateChanged && !detailChanged) {
return;
}
On4KstConnectionState previousState = lastDisplayedConnectionState;
Level logLevel = switch (newState) {
case DISCONNECTED, RECONNECT_WAIT -> Level.WARNING;
default -> Level.INFO;
};
LOGGER.log(logLevel,
"ON4KST connection indicator: {0} -> {1}; detail: {2}",
new Object[] {
previousState == null ? "UNINITIALIZED" : previousState,
newState,
newDetail
});
lastDisplayedConnectionState = newState;
lastDisplayedConnectionDetail = newDetail;
}
private void showBlinkingSkedWarnIndicator(String text) {
@@ -6590,6 +6715,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
flwpne_StatusBar.getChildren().add(mainScreenMenuBar);
bPaneChatWindow.setTop(flwpne_StatusBar);
initConnectionStateIndicatorButton();
flwpne_StatusBar.getChildren().add(btnConnectionStateIndicator);
initSkedWarnIndicatorButton();
chatcontroller.lastUiReminderEventProperty().addListener(
@@ -6949,9 +7077,25 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
else {
chatState = "DISCONNECTED!";
On4KstConnectionState connectionState =
chatcontroller.getOn4KstConnectionState();
chatState = switch (connectionState) {
case RECONNECT_WAIT ->
"CONNECTION LOST reconnect scheduled";
case CONNECTING ->
"Connecting to ON4KST…";
case WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING ->
"Connected authenticating with ON4KST…";
case SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT ->
"Connected synchronizing ON4KST chat data…";
default ->
"DISCONNECTED!";
};
chatcontroller.getChatPreferences().setChatState(chatState);
}
if (chatcontroller.isDisconnected()) {
chatState = "DISCONNECTED!";
chatcontroller.getChatPreferences().setChatState(chatState);
@@ -11578,8 +11722,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
txtFldstn_maxQRBDefault.setDisable(true);
btnOptionspnlConnect.setDisable(true);
btnOptionspnlDisconnect.setDisable(false);
chatcontroller.setConnectedAndLoggedIn(true);
chatcontroller.setDisconnected(false);
// chatcontroller.setConnectedAndLoggedIn(true);
// chatcontroller.setDisconnected(false);
station_chkBxEnableSecondChat.setDisable(true);
stn_choiceBxChatChategorySecond.setDisable(true);
}
@@ -11882,7 +12026,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
String logDir = Path.of(System.getProperty("user.home"), ".praktiKST").toString();
new File(logDir).mkdirs();
FileHandler fileHandler = new FileHandler(logDir + "/kst4contest-errors.log", true);
fileHandler.setLevel(Level.SEVERE);
// Connection loss/reconnect diagnostics are warnings, not fatal crashes.
fileHandler.setLevel(Level.WARNING);
fileHandler.setFormatter(new SimpleFormatter());
Logger rootLogger = Logger.getLogger("");
rootLogger.addHandler(fileHandler);
@@ -11892,17 +12037,77 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
@Override
public void onThreadStatusChanged(String key, ThreadStateMessage threadStateMessage) {
public void onThreadStatusChanged(
String key,
ThreadStateMessage threadStateMessage
) {
if (threadStateMessage == null) {
LOGGER.log(Level.WARNING,
"Ignoring empty thread-status update from source {0}",
key == null ? "UNKNOWN" : key);
return;
}
if (key == null || key.isBlank()) {
LOGGER.log(Level.WARNING,
"Ignoring thread-status update without a source key. Detail: {0}",
threadStateMessage.getRunningInformation());
return;
}
Platform.runLater(() -> {
updateStatusButton(key, threadStateMessage);
});
if ("ON4KST".equalsIgnoreCase(key)) {
String detail = threadStateMessage.getRunningInformation();
Platform.runLater(() -> updateConnectionStateIndicator(
chatcontroller.getOn4KstConnectionState(), detail));
return;
}
Platform.runLater(() -> updateStatusButton(key, threadStateMessage));
maybeShowBandUpgradeIndicator(key, threadStateMessage);
//if we receive a threadstatemessage for sked warning, enable the sked warning
}
}
public void onConnectionStateChanged(
On4KstConnectionState state,
String detail
) {
On4KstConnectionState effectiveState = state;
if (effectiveState == null) {
LOGGER.log(Level.WARNING,
"Received ON4KST connection callback without a state; "
+ "treating it as disconnected. Detail: {0}",
detail);
effectiveState = On4KstConnectionState.DISCONNECTED;
}
On4KstConnectionState stateToDisplay = effectiveState;
Platform.runLater(() -> {
boolean active = stateToDisplay.isConnectionAttemptActive();
boolean online = stateToDisplay.isOnline();
updateConnectionStateIndicator(stateToDisplay, detail);
if (menuItemFileConnect != null) {
menuItemFileConnect.setDisable(active);
}
if (menuItemFileDisconnect != null) {
menuItemFileDisconnect.setDisable(!active);
}
if (menuItemOptionsAwayBack != null) {
menuItemOptionsAwayBack.setDisable(!online);
}
if (menuItemOptionsSetFrequencyAsName != null) {
menuItemOptionsSetFrequencyAsName.setDisable(!online);
}
if (btnOptionspnlConnect != null) {
btnOptionspnlConnect.setDisable(active);
}
if (sendButton != null) {
sendButton.setDisable(!online);
}
if (txt_chatMessageUserInput != null) {
txt_chatMessageUserInput.setDisable(!online);
}
});
}
/**
+1
View File
@@ -10,6 +10,7 @@ module praktiKST {
requires java.net.http;
requires java.desktop;
requires jdk.crypto.ec;
requires jdk.net;
requires org.junit.jupiter.api;
requires org.mockito;
exports kst4contest.controller.interfaces;