mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-08-24 11:06:59 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9037adf6eb |
@@ -445,6 +445,7 @@
|
|||||||
<addmodule>java.sql</addmodule>
|
<addmodule>java.sql</addmodule>
|
||||||
<addmodule>java.net.http</addmodule>
|
<addmodule>java.net.http</addmodule>
|
||||||
<addmodule>jdk.crypto.ec</addmodule>
|
<addmodule>jdk.crypto.ec</addmodule>
|
||||||
|
<addmodule>jdk.net</addmodule>
|
||||||
</addmodules>
|
</addmodules>
|
||||||
<mainclass>${main.class}</mainclass>
|
<mainclass>${main.class}</mainclass>
|
||||||
<input>${project.build.directory}/modules</input>
|
<input>${project.build.directory}/modules</input>
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import java.util.function.Consumer;
|
|||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import kst4contest.logic.FrequencyTextParser;
|
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
|
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 disconnected;
|
||||||
boolean disconnectionPerformedByUser = false;
|
boolean disconnectionPerformedByUser = false;
|
||||||
|
|
||||||
|
private final On4KstConnectionManager on4KstConnectionManager =
|
||||||
|
new On4KstConnectionManager(this);
|
||||||
|
|
||||||
|
|
||||||
public boolean isDisconnectionPerformedByUser() {
|
public boolean isDisconnectionPerformedByUser() {
|
||||||
return disconnectionPerformedByUser;
|
return disconnectionPerformedByUser;
|
||||||
@@ -140,6 +148,15 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
return disconnected;
|
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) {
|
public void setDisconnected(boolean disconnected) {
|
||||||
this.disconnected = disconnected;
|
this.disconnected = disconnected;
|
||||||
}
|
}
|
||||||
@@ -161,6 +178,112 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
} else System.out.println("ERRRRRRRRRRRRRRRRRRRRRRRRRRRÖRRRRRRRRRRRRRRRRRRR");
|
} 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
|
* PSTRotator controlling
|
||||||
@@ -573,6 +696,16 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
*/
|
*/
|
||||||
public void disconnect(String action) {
|
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
|
// stopContextLoop(); //stops thread for calculating sked priorities
|
||||||
|
|
||||||
stopScoreScheduler();
|
stopScoreScheduler();
|
||||||
@@ -757,10 +890,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
// TODO Auto-generated catch block
|
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
} catch (Exception e2) {
|
} catch (Exception e2) {
|
||||||
// TODO Auto-generated catch block
|
|
||||||
e2.printStackTrace();
|
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();
|
// private ObservableList<ContestSked> activeSkeds = FXCollections.observableArrayList();
|
||||||
// public ObservableList<ContestSked> getActiveSkeds() {
|
// public ObservableList<ContestSked> getActiveSkeds() {
|
||||||
// return activeSkeds;
|
// return activeSkeds;
|
||||||
@@ -1416,6 +1610,10 @@ private ObservableList<String>
|
|||||||
private final List<ChatMessage> pendingChatMessages = new ArrayList<>();
|
private final List<ChatMessage> pendingChatMessages = new ArrayList<>();
|
||||||
private boolean chatMessageFlushScheduled = false;
|
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.
|
* Same idea for DXCluster messages.
|
||||||
*/
|
*/
|
||||||
@@ -1572,6 +1770,55 @@ private ObservableList<String>
|
|||||||
runOnFxThread(() -> lst_chatMemberList.clear());
|
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
|
* Resolves a member from the thread-safe active model. This avoids reading the
|
||||||
* TableView backing list from MessageBusManagementThread.
|
* TableView backing list from MessageBusManagementThread.
|
||||||
@@ -1913,6 +2160,9 @@ private ObservableList<String>
|
|||||||
if (message == null) {
|
if (message == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isDuplicateInboundMessage(message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
synchronized (pendingChatMessagesLock) {
|
synchronized (pendingChatMessagesLock) {
|
||||||
pendingChatMessages.add(message);
|
pendingChatMessages.add(message);
|
||||||
@@ -1927,6 +2177,47 @@ private ObservableList<String>
|
|||||||
Platform.runLater(this::flushPendingChatMessagesToUi);
|
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() {
|
private void flushPendingChatMessagesToUi() {
|
||||||
List<ChatMessage> batch;
|
List<ChatMessage> batch;
|
||||||
|
|
||||||
@@ -2615,6 +2906,10 @@ private ObservableList<String>
|
|||||||
*/
|
*/
|
||||||
public void execute() throws InterruptedException, IOException {
|
public void execute() throws InterruptedException, IOException {
|
||||||
|
|
||||||
|
if (on4KstConnectionManager != null) {
|
||||||
|
executeManaged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
chatController = this;
|
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.
|
* Returns the background reachability service used by the station table.
|
||||||
@@ -2964,6 +3303,11 @@ private ObservableList<String>
|
|||||||
*/
|
*/
|
||||||
public void initialize23001() throws InterruptedException, IOException {
|
public void initialize23001() throws InterruptedException, IOException {
|
||||||
|
|
||||||
|
if (on4KstConnectionManager != null) {
|
||||||
|
on4KstConnectionManager.start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
messageTXBus.clear();
|
messageTXBus.clear();
|
||||||
|
|
||||||
ChatMessage message = new ChatMessage();
|
ChatMessage message = new ChatMessage();
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import kst4contest.locatorUtils.DirectionUtils;
|
|||||||
import kst4contest.locatorUtils.Location;
|
import kst4contest.locatorUtils.Location;
|
||||||
import kst4contest.model.*;
|
import kst4contest.model.*;
|
||||||
import kst4contest.logic.FrequencyTextParser;
|
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 PrintWriter writer;
|
||||||
// private Socket socket;
|
// private Socket socket;
|
||||||
private ChatController client;
|
private ChatController client;
|
||||||
|
|
||||||
|
private final long connectionSessionId;
|
||||||
|
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
|
||||||
|
private final LongPredicate connectionSessionIsActive;
|
||||||
|
|
||||||
// private File fileLogRAW;
|
// private File fileLogRAW;
|
||||||
// private TimerTask userActualizationTask; // Is used as a temporary userout-print
|
// private TimerTask userActualizationTask; // Is used as a temporary userout-print
|
||||||
// private TimerTask userActualizationTask; //kst4contest.test 4 23001
|
// private TimerTask userActualizationTask; //kst4contest.test 4 23001
|
||||||
@@ -118,9 +125,22 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public MessageBusManagementThread(ChatController client, ThreadStatusCallback callBack) {
|
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.callBackToController = callBack;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
|
this.connectionSessionId = connectionSessionId;
|
||||||
|
this.receiveQueue = receiveQueue;
|
||||||
|
this.connectionSessionIsActive = connectionSessionIsActive;
|
||||||
|
|
||||||
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false);
|
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false);
|
||||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||||
@@ -522,7 +542,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
|
|
||||||
messageToProcess.setMessageText(reduce);
|
messageToProcess.setMessageText(reduce);
|
||||||
|
|
||||||
if (messageToProcess.getMessageText().isEmpty()) {
|
if (messageToProcess.getMessageText() == null
|
||||||
|
|| messageToProcess.getMessageText().isEmpty()) {
|
||||||
// System.out.println("[MSGBUSMGTT:] ###################### no processable data");
|
// System.out.println("[MSGBUSMGTT:] ###################### no processable data");
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
@@ -754,6 +775,50 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
return fallbackReceiver;
|
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)
|
* 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
|
* here we have a helper list for identifying questions for my qrg which can be autoanswered later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (messageToProcess.getMessageText().isEmpty()) {
|
if (messageToProcess.getMessageText() == null
|
||||||
// System.out.println("[MSGBUSMGTT:] no processable data");
|
|| messageToProcess.getMessageText().isEmpty()) {
|
||||||
|
// No processable data.
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
if (messageToProcess.getMessageText().contains(SRVR_LOGSTAT)) {
|
if (messageToProcess.getMessageText().startsWith(SRVR_LOGSTAT + "|")) {
|
||||||
String logstatMessage[];
|
String[] logstatMessage =
|
||||||
logstatMessage = messageToProcess.getMessageText().split("\\|");
|
messageToProcess.getMessageText().split("\\|", -1);
|
||||||
if (logstatMessage[1].contains(SRVR_LOGINOK)) {
|
this.client.onOn4KstLogstat(
|
||||||
this.client.setConnectedAndLoggedIn(true);
|
connectionSessionId,
|
||||||
} else {
|
logstatMessage);
|
||||||
this.client.setConnectedAndNOTLoggedIn(true);
|
|
||||||
this.client.setConnectedAndLoggedIn(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
* 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");
|
// System.out.println("MSGBUS: User detected");
|
||||||
|
|
||||||
ChatMember newMember = new ChatMember();
|
ChatMember newMember = new ChatMember();
|
||||||
@@ -853,7 +928,9 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
|
|
||||||
|
|
||||||
if (!client.getChatPreferences().getStn_loginCallSign().equals(newMember.getCallSign())) {
|
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);
|
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
|
||||||
// Reachability is calculated on demand only: map click, selected station, or manual request.
|
// 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|
|
* 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");
|
// System.out.println("MSGBUS: User detected");
|
||||||
|
|
||||||
|
|
||||||
@@ -1640,18 +1718,29 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
/**
|
/**
|
||||||
* Userinfo-update: UE|2|22562|
|
* 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
|
this.client.onOn4KstInitialUserListCompleted(
|
||||||
} else
|
connectionSessionId,
|
||||||
|
util_getChatCategoryByCategoryNrString(
|
||||||
|
splittedMessageLine[1]));
|
||||||
|
|
||||||
if (splittedMessageLine[0].contains(SRVR_DXCEND)) {
|
} else if (SRVR_DXCEND.equals(opcode)) {
|
||||||
|
|
||||||
// No worthy information, count of users
|
// DF marks the end of the initial DX-cluster data.
|
||||||
} else
|
// 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
|
} else
|
||||||
|
|
||||||
//-> LOGSTAT|114|Wrong password!|
|
//-> LOGSTAT|114|Wrong password!|
|
||||||
@@ -1931,13 +2020,17 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
while (true) {
|
while (true) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
messageTextRaw = client.getMessageRXBus().take();
|
messageTextRaw = receiveQueue.take();
|
||||||
|
|
||||||
if (messageTextRaw.getMessageText().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL) && messageTextRaw.getMessageSenderName().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
|
if (ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(messageTextRaw.getMessageText())
|
||||||
client.getMessageRXBus().clear();
|
&& ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(messageTextRaw.getMessageSenderName())) {
|
||||||
|
receiveQueue.clear();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
if (!connectionSessionIsActive.test(connectionSessionId)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
messageLine = messageTextRaw.getMessageText();
|
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;
|
package kst4contest.controller;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.BufferedReader;
|
||||||
import java.net.*;
|
import java.io.EOFException;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.net.Socket;
|
||||||
import java.nio.charset.StandardCharsets;
|
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.Level;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
import kst4contest.model.ChatMessage;
|
import kst4contest.model.ChatMessage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This thread is responsible for reading telnet servers input at port 23001 and printing it
|
* Reads exactly one immutable ON4KST connection session.
|
||||||
* to the console.
|
|
||||||
* It runs in an infinite loop until the client disconnects from the server.
|
|
||||||
*
|
*
|
||||||
* @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 {
|
public class ReadThread extends Thread {
|
||||||
private static final Logger LOGGER = Logger.getLogger(ReadThread.class.getName());
|
private static final Logger LOGGER = Logger.getLogger(ReadThread.class.getName());
|
||||||
private BufferedReader reader;
|
|
||||||
private Socket socket;
|
|
||||||
private ChatController client;
|
|
||||||
public boolean 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 -> { });
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isAccidentalDisconnected() {
|
/**
|
||||||
return accidentalDisconnected;
|
* Creates the reader for one connection generation.
|
||||||
}
|
*
|
||||||
|
* @param sessionId immutable id of the owning socket session
|
||||||
public void setAccidentalDisconnected(boolean accidentalDisconnected) {
|
* @param socket connected ON4KST socket
|
||||||
this.accidentalDisconnected = accidentalDisconnected;
|
* @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
|
||||||
// private boolean readingFinished = true; //kst4contest.test 4 23001
|
* @param connectionFailure callback for EOF, I/O and unexpected runtime errors
|
||||||
private boolean readingFinished = true;
|
* @throws IOException if the socket input stream cannot be opened
|
||||||
|
*/
|
||||||
InputStream input;
|
public ReadThread(
|
||||||
|
long sessionId,
|
||||||
public ReadThread(Socket socket, ChatController client) {
|
Socket socket,
|
||||||
|
LinkedBlockingQueue<ChatMessage> receiveQueue,
|
||||||
|
LongPredicate sessionIsActive,
|
||||||
|
Consumer<String> inboundActivity,
|
||||||
|
Consumer<Throwable> connectionFailure
|
||||||
|
) throws IOException {
|
||||||
|
this.sessionId = sessionId;
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
this.client = client;
|
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("ReadFromOn4Kst-" + sessionId);
|
||||||
|
LOGGER.log(Level.FINE,
|
||||||
|
"ON4KST reader started for session {0}", sessionId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
input = socket.getInputStream();
|
while (!isInterrupted() && sessionIsActive.test(sessionId)) {
|
||||||
reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
|
|
||||||
|
|
||||||
} catch (IOException ex) {
|
|
||||||
LOGGER.log(Level.SEVERE, "Error getting input stream", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
String response = reader.readLine();
|
||||||
message = new ChatMessage();
|
if (response == null) {
|
||||||
message.setMessageText(response);
|
throw new EOFException("ON4KST closed the TCP connection");
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inboundActivity.accept(response);
|
||||||
|
if (!sessionIsActive.test(sessionId)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatMessage message = new ChatMessage();
|
||||||
|
message.setMessageText(response);
|
||||||
|
receiveQueue.put(message);
|
||||||
}
|
}
|
||||||
catch (Exception sexc) {
|
} catch (InterruptedException interrupted) {
|
||||||
LOGGER.log(Level.SEVERE, "[ReadThread] Socket closed unexpectedly", sexc);
|
Thread.currentThread().interrupt();
|
||||||
try {
|
} catch (IOException exception) {
|
||||||
this.client.getSocket().close();
|
if (sessionIsActive.test(sessionId)) {
|
||||||
this.interrupt();
|
LOGGER.log(Level.FINE,
|
||||||
break;
|
"ON4KST read failed for session " + sessionId,
|
||||||
|
exception);
|
||||||
} catch (IOException e) {
|
connectionFailure.accept(exception);
|
||||||
LOGGER.log(Level.SEVERE, "[ReadThread] Error closing socket", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
} 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 {
|
public boolean terminateConnection() throws IOException {
|
||||||
this.reader.close();
|
interrupt();
|
||||||
this.input.close();
|
reader.close();
|
||||||
this.socket.close();
|
socket.close();
|
||||||
|
return true;
|
||||||
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);
|
void onUserListUpdated(String reason);
|
||||||
// new: userlist-update
|
// 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;
|
package kst4contest.controller;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.BufferedWriter;
|
||||||
import java.net.*;
|
import java.io.IOException;
|
||||||
import java.nio.charset.Charset;
|
import java.io.OutputStreamWriter;
|
||||||
|
import java.net.Socket;
|
||||||
import java.nio.charset.StandardCharsets;
|
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.ApplicationConstants;
|
||||||
|
import kst4contest.model.ChatCategory;
|
||||||
import kst4contest.model.ChatMessage;
|
import kst4contest.model.ChatMessage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This thread is responsible for sending content to the chat. As we only use
|
* Serializes and writes exactly one immutable ON4KST connection session.
|
||||||
* the tx function, there is no content in run() method
|
|
||||||
*
|
|
||||||
*
|
*
|
||||||
|
* <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 {
|
public class WriteThread extends Thread {
|
||||||
private PrintWriter writer;
|
private static final Logger LOGGER =
|
||||||
private Socket socket;
|
Logger.getLogger(WriteThread.class.getName());
|
||||||
private ChatController client;
|
private final long sessionId;
|
||||||
private OutputStream output;
|
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;
|
||||||
|
|
||||||
private ChatMessage messageToBeSend;
|
/**
|
||||||
|
* Compatibility constructor for the pre-session controller path.
|
||||||
|
*
|
||||||
|
* @deprecated new connections should be created by
|
||||||
|
* {@link On4KstConnectionManager}
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
|
public WriteThread(Socket socket, ChatController client) throws IOException {
|
||||||
|
this(0L, socket, client.getMessageTXBus(),
|
||||||
|
client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber(),
|
||||||
|
ignored -> true,
|
||||||
|
ignored -> { }, System.out::println);
|
||||||
|
}
|
||||||
|
|
||||||
public WriteThread(Socket socket, ChatController client) 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 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.socket = socket;
|
||||||
this.client = client;
|
this.transmitQueue = transmitQueue;
|
||||||
|
this.defaultCategory = defaultCategory;
|
||||||
try {
|
this.sessionIsActive = sessionIsActive;
|
||||||
output = socket.getOutputStream();
|
this.connectionFailure = connectionFailure;
|
||||||
|
this.rejectedFrame = rejectedFrame;
|
||||||
writer = new PrintWriter(output, true, StandardCharsets.UTF_8);
|
this.writer = new BufferedWriter(new OutputStreamWriter(
|
||||||
|
socket.getOutputStream(), StandardCharsets.UTF_8));
|
||||||
} catch (IOException ex) {
|
|
||||||
System.out.println("Error getting output stream: " + ex.getMessage());
|
|
||||||
ex.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
*
|
|
||||||
* @param messageToServer
|
|
||||||
* @throws InterruptedException
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
public void txKSTFormatted(ChatMessage messageToServer) throws InterruptedException {
|
|
||||||
|
|
||||||
// writer.println(messageToServer.getMessageText());
|
|
||||||
messageToBeSend = messageToServer;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
Thread.currentThread().setName("WriteToTelnetThread");
|
Thread.currentThread().setName("WriteToOn4Kst-" + sessionId);
|
||||||
|
LOGGER.log(Level.FINE,
|
||||||
|
"ON4KST writer started for session {0}", sessionId);
|
||||||
|
|
||||||
while (true) {
|
try {
|
||||||
try {
|
while (!isInterrupted() && sessionIsActive.test(sessionId)) {
|
||||||
messageToBeSend = client.getMessageTXBus().take();
|
ChatMessage message = transmitQueue.take();
|
||||||
|
if (isPoisonPill(message)) {
|
||||||
if (messageToBeSend.getMessageText().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)
|
break;
|
||||||
&& messageToBeSend.getMessageSenderName().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
|
}
|
||||||
client.getMessageRXBus().clear();
|
if (!sessionIsActive.test(sessionId)) {
|
||||||
this.interrupt();
|
|
||||||
break;
|
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());
|
try {
|
||||||
|
writeFrame(formatFrame(message));
|
||||||
// this.client.getmesetChatsetServerready(true);
|
} catch (IllegalArgumentException invalidFrame) {
|
||||||
|
LOGGER.log(Level.FINE,
|
||||||
} catch (InterruptedException e) {
|
"Rejected outbound ON4KST frame in session {0}: {1}",
|
||||||
e.printStackTrace();
|
new Object[] {sessionId, invalidFrame.getMessage()});
|
||||||
client.getMessageTXBus().clear();
|
rejectedFrame.accept(invalidFrame.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (InterruptedException interrupted) {
|
||||||
// String messageLine = messageTextRaw.getMessageText();
|
Thread.currentThread().interrupt();
|
||||||
//
|
} catch (IOException exception) {
|
||||||
// if (messageTextRaw.isMessageDirectedToServer()) {
|
if (sessionIsActive.test(sessionId)) {
|
||||||
// /**
|
LOGGER.log(Level.FINE,
|
||||||
// * We have to check if we only commands the server (keepalive) or want do talk
|
"ON4KST write failed for session " + sessionId,
|
||||||
// * to the community
|
exception);
|
||||||
// */
|
connectionFailure.accept(exception);
|
||||||
//
|
|
||||||
// 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();
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
// if (messageTextRaw.equals("/QUIT")) {
|
} catch (RuntimeException exception) {
|
||||||
// try {
|
if (sessionIsActive.test(sessionId)) {
|
||||||
// this.client.getReadThread().terminateConnection();
|
LOGGER.log(Level.SEVERE,
|
||||||
// this.client.getReadThread().interrupt();
|
"Unexpected ON4KST writer failure for session "
|
||||||
// this.client.getWriteThread().terminateConnection();
|
+ sessionId,
|
||||||
// this.client.getWriteThread().interrupt();
|
exception);
|
||||||
// this.interrupt();
|
connectionFailure.accept(exception);
|
||||||
//
|
}
|
||||||
// } catch (IOException e) {
|
} finally {
|
||||||
// // TODO Auto-generated catch block
|
LOGGER.log(Level.FINE,
|
||||||
// e.printStackTrace();
|
"ON4KST writer stopped for session {0}", sessionId);
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
|
private String formatFrame(ChatMessage message) {
|
||||||
|
if (message == null) {
|
||||||
|
throw new IllegalArgumentException("Cannot send an empty ON4KST message");
|
||||||
|
}
|
||||||
|
|
||||||
// while (true) {
|
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;
|
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 {
|
public class keepAliveMessageSenderTask extends TimerTask {
|
||||||
|
|
||||||
private ChatController client;
|
private ChatController client;
|
||||||
@@ -22,7 +29,8 @@ public class keepAliveMessageSenderTask extends TimerTask {
|
|||||||
// System.out.println("[keepalive: ] Thread runned now");
|
// System.out.println("[keepalive: ] Thread runned now");
|
||||||
|
|
||||||
ChatMessage keepAliveMSG = new ChatMessage();
|
ChatMessage keepAliveMSG = new ChatMessage();
|
||||||
keepAliveMSG.setMessageText("\r");
|
// WriteThread appends exactly one CRLF. An empty frame is the protocol reply.
|
||||||
|
keepAliveMSG.setMessageText("");
|
||||||
keepAliveMSG.setMessageDirectedToServer(true);
|
keepAliveMSG.setMessageDirectedToServer(true);
|
||||||
|
|
||||||
// System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() + " [keepaliveTask]: Sending keepalive: "
|
// System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() + " [keepaliveTask]: Sending keepalive: "
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kst4contest.view;
|
package kst4contest.view;
|
||||||
import kst4contest.utils.VersionUtils;
|
import kst4contest.utils.VersionUtils;
|
||||||
|
import kst4contest.controller.On4KstConnectionState;
|
||||||
|
|
||||||
import javafx.scene.image.Image;
|
import javafx.scene.image.Image;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@@ -81,6 +82,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
// Enables optional color highlighting of the QRA/grid cell.
|
// Enables optional color highlighting of the QRA/grid cell.
|
||||||
// The status text itself remains visible independently of this flag.
|
// The status text itself remains visible independently of this flag.
|
||||||
|
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(
|
||||||
|
Kst4ContestApplication.class.getName());
|
||||||
|
|
||||||
private boolean gridSquareHighlightEnabled = false;
|
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 StationMapView stationMapView; //view class for the avl stn map
|
||||||
private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view
|
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 Button btnBandUpgradeIndicator = new Button("BAND+");
|
||||||
private final Tooltip tipBandUpgradeIndicator = new Tooltip();
|
private final Tooltip tipBandUpgradeIndicator = new Tooltip();
|
||||||
private Timeline bandUpgradeBlinkTimeline;
|
private Timeline bandUpgradeBlinkTimeline;
|
||||||
@@ -5320,8 +5330,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
menuItemOptionsAwayBack.setDisable(false);
|
menuItemOptionsAwayBack.setDisable(false);
|
||||||
menuItemOptionsSetFrequencyAsName.setDisable(false);
|
menuItemOptionsSetFrequencyAsName.setDisable(false);
|
||||||
|
|
||||||
chatcontroller.setConnectedAndLoggedIn(true);
|
// chatcontroller.setConnectedAndLoggedIn(true);
|
||||||
chatcontroller.setDisconnected(false);
|
// chatcontroller.setDisconnected(false);
|
||||||
|
|
||||||
} catch (InterruptedException | IOException e) {
|
} catch (InterruptedException | IOException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
@@ -5643,6 +5653,121 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
btnSkedWarnIndicator.setTooltip(tipSkedWarnIndicator);
|
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) {
|
private void showBlinkingSkedWarnIndicator(String text) {
|
||||||
@@ -6590,6 +6715,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
flwpne_StatusBar.getChildren().add(mainScreenMenuBar);
|
flwpne_StatusBar.getChildren().add(mainScreenMenuBar);
|
||||||
bPaneChatWindow.setTop(flwpne_StatusBar);
|
bPaneChatWindow.setTop(flwpne_StatusBar);
|
||||||
|
|
||||||
|
initConnectionStateIndicatorButton();
|
||||||
|
flwpne_StatusBar.getChildren().add(btnConnectionStateIndicator);
|
||||||
|
|
||||||
initSkedWarnIndicatorButton();
|
initSkedWarnIndicatorButton();
|
||||||
|
|
||||||
chatcontroller.lastUiReminderEventProperty().addListener(
|
chatcontroller.lastUiReminderEventProperty().addListener(
|
||||||
@@ -6949,9 +7077,25 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
}
|
}
|
||||||
|
|
||||||
else {
|
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);
|
chatcontroller.getChatPreferences().setChatState(chatState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chatcontroller.isDisconnected()) {
|
if (chatcontroller.isDisconnected()) {
|
||||||
chatState = "DISCONNECTED!";
|
chatState = "DISCONNECTED!";
|
||||||
chatcontroller.getChatPreferences().setChatState(chatState);
|
chatcontroller.getChatPreferences().setChatState(chatState);
|
||||||
@@ -11578,8 +11722,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
txtFldstn_maxQRBDefault.setDisable(true);
|
txtFldstn_maxQRBDefault.setDisable(true);
|
||||||
btnOptionspnlConnect.setDisable(true);
|
btnOptionspnlConnect.setDisable(true);
|
||||||
btnOptionspnlDisconnect.setDisable(false);
|
btnOptionspnlDisconnect.setDisable(false);
|
||||||
chatcontroller.setConnectedAndLoggedIn(true);
|
// chatcontroller.setConnectedAndLoggedIn(true);
|
||||||
chatcontroller.setDisconnected(false);
|
// chatcontroller.setDisconnected(false);
|
||||||
station_chkBxEnableSecondChat.setDisable(true);
|
station_chkBxEnableSecondChat.setDisable(true);
|
||||||
stn_choiceBxChatChategorySecond.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();
|
String logDir = Path.of(System.getProperty("user.home"), ".praktiKST").toString();
|
||||||
new File(logDir).mkdirs();
|
new File(logDir).mkdirs();
|
||||||
FileHandler fileHandler = new FileHandler(logDir + "/kst4contest-errors.log", true);
|
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());
|
fileHandler.setFormatter(new SimpleFormatter());
|
||||||
Logger rootLogger = Logger.getLogger("");
|
Logger rootLogger = Logger.getLogger("");
|
||||||
rootLogger.addHandler(fileHandler);
|
rootLogger.addHandler(fileHandler);
|
||||||
@@ -11892,17 +12037,77 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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(() -> {
|
if ("ON4KST".equalsIgnoreCase(key)) {
|
||||||
updateStatusButton(key, threadStateMessage);
|
String detail = threadStateMessage.getRunningInformation();
|
||||||
});
|
Platform.runLater(() -> updateConnectionStateIndicator(
|
||||||
|
chatcontroller.getOn4KstConnectionState(), detail));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Platform.runLater(() -> updateStatusButton(key, threadStateMessage));
|
||||||
maybeShowBandUpgradeIndicator(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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ module praktiKST {
|
|||||||
requires java.net.http;
|
requires java.net.http;
|
||||||
requires java.desktop;
|
requires java.desktop;
|
||||||
requires jdk.crypto.ec;
|
requires jdk.crypto.ec;
|
||||||
|
requires jdk.net;
|
||||||
requires org.junit.jupiter.api;
|
requires org.junit.jupiter.api;
|
||||||
requires org.mockito;
|
requires org.mockito;
|
||||||
exports kst4contest.controller.interfaces;
|
exports kst4contest.controller.interfaces;
|
||||||
|
|||||||
Reference in New Issue
Block a user