Files
kst4contest/src/main/java/kst4contest/controller/ChatController.java
T

4316 lines
130 KiB
Java

package kst4contest.controller;
import java.net.*;
import java.nio.ByteBuffer;
import java.nio.channels.Selector;
import java.nio.file.Path;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import javafx.application.Platform;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.collections.transformation.SortedList;
import kst4contest.ApplicationConstants;
import kst4contest.controller.interfaces.PstRotatorEventListener;
import kst4contest.locatorUtils.DirectionUtils;
import kst4contest.logic.BandOpportunityResolver;
import kst4contest.logic.PriorityCalculator;
import kst4contest.model.*;
import kst4contest.test.MockKstServer;
import kst4contest.utils.PlayAudioUtils;
import kst4contest.locatorUtils.Location;
import kst4contest.view.Kst4ContestApplication;
import java.io.*;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.nio.charset.StandardCharsets;
import kst4contest.logic.FrequencyTextParser;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.concurrent.ScheduledFuture;
/**
*
* Central Chat kst4contest.controller. Instantiate only one time per category of kst Chat.
* Provides complex data types for communication with the gui and drives the
* threads of telnet tx, telnet rx and message processing. <br/>
* <b>SINGLETON</b>
*
*/
public class ChatController implements ThreadStatusCallback, PstRotatorEventListener {
/**
* Chat selection ? 50/70 MHz..............1 144/432 MHz............2
* Microwave..............3 EME/JT65...............4 Low Band...............5 50
* MHz IARU Region 3...6 50 MHz IARU Region 2...7 144/432 MHz IARU R 2...8
* 144/432 MHz IARU R 3...9 kHz (2000-630m).......10 Warc (30,17,12m)......11 28
* MHz................12 Your choice :
*
*/
private static final Logger LOGGER =
Logger.getLogger(ChatController.class.getName());
private static final boolean DEBUG_BAND_UPGRADE_HINT = true; //for new band hint
public static final int MIN_BEACON_INTERVAL_MINUTES = 1;
public static final int MAX_BEACON_TEXT_LENGTH = 120;
private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L;
private volatile PstRotatorClient rotatorClient;
private Consumer<Double> viewRotorCallback;
/*
* The rotator retry must never block the JavaFX Application Thread.
* A daemon scheduler performs the delayed SPID compatibility check.
*/
private final ScheduledExecutorService rotatorCommandScheduler =
Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(
runnable,
"PSTRotator-Command-Retry"
);
thread.setDaemon(true);
return thread;
});
private ScheduledFuture<?> pendingRotatorRetry;
/*
* Updated directly by the PSTRotator receiver thread. This avoids reading
* a JavaFX property from the command scheduler.
*/
private volatile double lastReportedRotatorAzimuth = Double.NaN;
private Kst4ContestApplication view; //effectively final, for recoupling of the controller to the view
private StatusUpdateListener statusListener; //update info interface for the threads
public void setView(Kst4ContestApplication view) {
this.view = view;
}
private UpdateInformation updateInformation;
private ChatPreferences chatPreferences;
private ChatCategory chatCategoryMain;
private ChatCategory chatCategorySecondChat;
boolean connectedAndLoggedIn;
boolean connectedAndNOTLoggedIn;
boolean disconnected;
boolean disconnectionPerformedByUser = false;
private final On4KstConnectionManager on4KstConnectionManager =
new On4KstConnectionManager(this);
public boolean isDisconnectionPerformedByUser() {
return disconnectionPerformedByUser;
}
public void setDisconnectionPerformedByUser(boolean disconnectionPerformedByUser) {
this.disconnectionPerformedByUser = disconnectionPerformedByUser;
}
public ChatCategory getChatCategorySecondChat() {
return chatCategorySecondChat;
}
public void setChatCategorySecondChat(ChatCategory chatCategorySecondChat) {
this.chatCategorySecondChat = chatCategorySecondChat;
}
public UpdateInformation getUpdateInformation() {
return updateInformation;
}
public void setUpdateInformation(UpdateInformation updateInformation) {
this.updateInformation = updateInformation;
}
public String getChatState() {
return chatState;
}
public void setChatState(String chatState) {
this.chatState = chatState;
}
public boolean isConnectedAndLoggedIn() {
return connectedAndLoggedIn;
}
public void setConnectedAndLoggedIn(boolean connectedAndLoggedIn) {
this.connectedAndLoggedIn = connectedAndLoggedIn;
}
public boolean isConnectedAndNOTLoggedIn() {
return connectedAndNOTLoggedIn;
}
public void setConnectedAndNOTLoggedIn(boolean connectedAndNOTLoggedIn) {
this.connectedAndNOTLoggedIn = connectedAndNOTLoggedIn;
}
public boolean isDisconnected() {
return disconnected;
}
/**
* Returns the authoritative ON4KST lifecycle state.
*
* @return current connection, authentication or synchronization state
*/
public On4KstConnectionState getOn4KstConnectionState() {
return on4KstConnectionManager.getState();
}
public void setDisconnected(boolean disconnected) {
this.disconnected = disconnected;
}
public StatusUpdateListener getStatusListener() {
return statusListener;
}
public void setStatusListener(StatusUpdateListener statusListener) {
this.statusListener = statusListener;
}
@Override
public void onThreadStatus(String threadName, ThreadStateMessage threadStateMessage) {
// Weiterleiten an die View
if (statusListener != null) {
statusListener.onThreadStatusChanged(threadName, threadStateMessage);
} 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
*******************************************************************************/
public void initRotor() {
rotatorClient = new PstRotatorClient(
chatPreferences.getStn_pstRotatorHost(),
chatPreferences.getStn_pstRotatorPort(),
this,
this
);
rotatorClient.start();
}
/**
* Sends a new azimuth to PSTRotator without blocking the JavaFX thread.
*
* <p>Some SPID configurations occasionally ignore the first azimuth
* command. KST4Contest therefore checks the latest reported position after
* two seconds. If no movement was reported and the target has not already
* been reached, the original compatibility sequence is sent again.</p>
*
* @param azimuth required antenna azimuth in degrees
*/
public void rotateTo(double azimuth) {
if (!Double.isFinite(azimuth)) {
LOGGER.log(
Level.WARNING,
"Ignoring invalid PSTRotator azimuth: {0}",
azimuth
);
return;
}
PstRotatorClient activeClient = rotatorClient;
if (activeClient == null) {
LOGGER.log(
Level.WARNING,
"Cannot rotate antenna to {0} degrees: "
+ "PSTRotator integration is not active.",
azimuth
);
return;
}
double targetAzimuth = normalizeAzimuth(azimuth);
double positionBeforeCommand = lastReportedRotatorAzimuth;
activeClient.setTrackingMode(false);
LOGGER.log(
Level.INFO,
"Sending PSTRotator azimuth requested by the operator: {0}",
targetAzimuth
);
activeClient.setAzimuth(targetAzimuth);
ScheduledFuture<?> previousRetry = pendingRotatorRetry;
if (previousRetry != null) {
previousRetry.cancel(false);
}
pendingRotatorRetry = rotatorCommandScheduler.schedule(
() -> retryRotatorCommandIfRequired(
positionBeforeCommand,
targetAzimuth
),
2,
TimeUnit.SECONDS
);
}
/**
* Performs the delayed SPID compatibility check.
*
* <p>No retry is required when the requested position has already been
* reached or when PSTRotator reported movement after the original command.
* Missing feedback is treated like an unchanged position.</p>
*/
private void retryRotatorCommandIfRequired(
double positionBeforeCommand,
double targetAzimuth
) {
PstRotatorClient activeClient = rotatorClient;
if (activeClient == null) {
return;
}
double currentAzimuth = lastReportedRotatorAzimuth;
if (Double.isFinite(currentAzimuth)
&& angularDistance(currentAzimuth, targetAzimuth) < 0.5) {
LOGGER.log(
Level.FINE,
"PSTRotator reached the requested azimuth without retry: {0}",
targetAzimuth
);
return;
}
boolean noPositionFeedback =
!Double.isFinite(currentAzimuth);
boolean positionUnchanged =
Double.isFinite(positionBeforeCommand)
&& Double.isFinite(currentAzimuth)
&& angularDistance(
positionBeforeCommand,
currentAzimuth
) < 0.5;
if (!noPositionFeedback && !positionUnchanged) {
LOGGER.log(
Level.FINE,
"PSTRotator reported movement towards {0}; no retry required.",
targetAzimuth
);
return;
}
LOGGER.log(
Level.WARNING,
"PSTRotator reported no movement after the command for {0} degrees; "
+ "sending the SPID compatibility retry.",
targetAzimuth
);
activeClient.setAzimuth(0.0);
activeClient.setAzimuth(targetAzimuth);
}
/**
* Returns the smallest angular distance between two azimuth values.
*/
private static double angularDistance(double first, double second) {
double difference = Math.abs(
normalizeAzimuth(first) - normalizeAzimuth(second)
);
return Math.min(difference, 360.0 - difference);
}
/**
* Normalises an azimuth to the range from 0 inclusive to 360 exclusive.
*/
private static double normalizeAzimuth(double azimuth) {
double normalized = azimuth % 360.0;
return normalized < 0.0 ? normalized + 360.0 : normalized;
}
/**
* Called when an external logger reports that a QSO was logged.
*
* <p>The common resolver combines recent QRG evidence and station-name bands
* across every active callsignRaw variant. A manual NOT-QRV tag overrides this
* automatic evidence before the hint and priority boost are evaluated.</p>
*/
public void onExternalLogEntryReceived(String callSignRaw) {
if (callSignRaw == null || callSignRaw.isBlank()) return;
if (chatPreferences == null) return;
if (!chatPreferences.isNotify_bandUpgradeHintOnLogEnabled()) return;
final String callRaw = normalizeCallRaw(callSignRaw);
if (DEBUG_BAND_UPGRADE_HINT) {
System.out.println("[BandUpgradeHint] LOG received for call=" + callRaw);
}
EnumSet<Band> myEnabledBands =
BandOpportunityResolver.getEnabledStationBands(chatPreferences);
if (myEnabledBands.isEmpty()) return;
List<ChatMember> variants = findActiveChatMembersByRawCall(callRaw);
BandOpportunityResolver.Resolution bandResolution =
BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
EnumSet<Band> stationOfferedBands = bandResolution.getOfferedBands();
if (stationOfferedBands.isEmpty()) return;
EnumSet<Band> workedBands = bandResolution.getWorkedBands();
EnumSet<Band> remainingBands =
bandResolution.getUnworkedEnabledBands(myEnabledBands);
if (remainingBands.isEmpty()) return;
if (DEBUG_BAND_UPGRADE_HINT) {
System.out.println("[BandUpgradeHint] call=" + callRaw
+ " enabled=" + formatBandsHuman(myEnabledBands)
+ " offered=" + formatBandsHuman(stationOfferedBands)
+ " worked=" + (workedBands.isEmpty() ? "-" : formatBandsHuman(workedBands))
+ " NOT-QRV=" + formatBandsHuman(bandResolution.getNotQrvBands())
+ " remaining=" + formatBandsHuman(remainingBands));
}
String remainingHuman = formatBandsHuman(remainingBands);
String shortText = "BAND+ " + callRaw + " " + remainingHuman;
String tooltip = "Logged " + callRaw
+ ", but the station still offers additional band(s): "
+ remainingHuman
+ "\n(Enabled: " + formatBandsHuman(myEnabledBands)
+ " | Worked: " + (workedBands.isEmpty() ? "-" : formatBandsHuman(workedBands))
+ " | NOT QRV: " + formatBandsHuman(bandResolution.getNotQrvBands()) + ")";
ThreadStateMessage msg = new ThreadStateMessage("BandUpgradeHint", true, tooltip, false);
msg.setRunningInformationTextDescription(shortText);
onThreadStatus("BandUpgradeHint", msg);
if (chatPreferences.isNotify_playSimpleSounds()) {
try {
getPlayAudioUtils().playNoiseLauncher('!');
} catch (Exception e) {
System.out.println(
"[ChatController, warning]: failed to play band-upgrade hint sound: "
+ e.getMessage()
);
}
}
if (getScoreService() != null) {
getScoreService().requestRecompute("BandUpgradeHint");
}
}
/** Normalize callsign raw to a stable key for comparisons. */
private static String normalizeCallRaw(String callRaw) {
return callRaw.trim().toUpperCase(Locale.ROOT);
}
private static String formatBandsHuman(EnumSet<Band> bands) {
if (bands == null || bands.isEmpty()) return "-";
return bands.stream().map(ChatController::bandToHumanLabel).sorted().reduce((a, b) -> a + ", " + b).orElse("-");
}
private static String bandToHumanLabel(Band b) {
if (b == null) return "?";
return switch (b) {
case B_50 -> "6m";
case B_70 -> "4m";
case B_144 -> "2m";
case B_432 -> "70cm";
case B_1296 -> "23cm";
case B_2320 -> "13cm";
case B_3400 -> "9cm";
case B_5760 -> "6cm";
case B_10G -> "3cm";
case B_24G -> "1.2cm";
default -> b.name();
};
}
public void stopRotator() {
ScheduledFuture<?> pendingRetry = pendingRotatorRetry;
if (pendingRetry != null) {
pendingRetry.cancel(false);
pendingRotatorRetry = null;
}
PstRotatorClient activeClient = rotatorClient;
rotatorClient = null;
lastReportedRotatorAzimuth = Double.NaN;
if (activeClient != null) {
activeClient.stop();
}
}
@Override
public void onAzimuthUpdate(double azimuth) {
if (!Double.isFinite(azimuth)) {
LOGGER.log(
Level.WARNING,
"Ignoring invalid azimuth reported by PSTRotator: {0}",
azimuth
);
return;
}
double normalizedAzimuth = normalizeAzimuth(azimuth);
lastReportedRotatorAzimuth = normalizedAzimuth;
/*
* The callback runs in the PSTRotator receiver thread. JavaFX properties
* must be updated on the JavaFX Application Thread.
*/
Runnable fxUpdate = () ->
chatPreferences.getActualQTF().setValue(normalizedAzimuth);
if (Platform.isFxApplicationThread()) {
fxUpdate.run();
} else {
Platform.runLater(fxUpdate);
}
}
@Override
public void onElevationUpdate(double elevation) {
// System.out.println("Neue Elevation: " + elevation);
//not used in first version
}
@Override
public void onModeUpdate(boolean isTracking) {
// System.out.println("Modus: " + (isTracking ? "Tracking" : "Manuell"));
//not used in first version
}
@Override
public void onMessageReceived(String raw) {
// Logging
}
/**
* Helping
* @param targetCallSignRaw
* @param preferredCategory
* @param messageAfterCq
*/
public void queuePrivateCqMessage(String targetCallSignRaw, ChatCategory preferredCategory, String messageAfterCq) {
if (targetCallSignRaw == null || targetCallSignRaw.isBlank()) return;
ChatCategory categoryToUse = preferredCategory;
if (categoryToUse == null) {
ChatCategory last = lastInboundCategoryByCallSignRaw.get(targetCallSignRaw.trim().toUpperCase());
categoryToUse = (last != null) ? last : chatCategoryMain;
}
String text = "/cq " + targetCallSignRaw.trim().toUpperCase() + " " + (messageAfterCq == null ? "" : messageAfterCq);
ChatMessage msg = new ChatMessage();
msg.setChatCategory(categoryToUse);
msg.setMessageText(text);
msg.setMessageDirectedToServer(false);
messageTXBus.add(msg);
// Metrics: treat this as an outbound ping
stationMetricsService.tryRecordOutboundCq(text, System.currentTimeMillis());
// Scoring should react quickly to outbound actions
if (scoreService != null) {
scoreService.requestRecompute("outbound-cq");
}
}
/**
*
* @param remoteChatMember with callsign of the foreign station
*/
/**
* Requests AirScout to display the path between the own station and the
* selected remote station.
*
* The configured server identifier, client identifier, band and UDP port are
* used for every request. This allows several AirScout servers or clients to
* coexist in the same network.
*
* @param remoteChatMember selected remote station
*/
public void airScout_SendAsShowPathPacket(
ChatMember remoteChatMember
) {
if (!chatPreferences.isAirScout_asUDPListenerEnabled()) {
System.out.println(
"[AirScout, info]: Show-path request ignored because "
+ "the AirScout integration is disabled."
);
return;
}
if (remoteChatMember == null
|| remoteChatMember.getCallSign() == null
|| remoteChatMember.getCallSign().isBlank()
|| remoteChatMember.getQra() == null
|| remoteChatMember.getQra().isBlank()) {
System.out.println(
"[AirScout, warning]: Show-path request ignored because "
+ "the selected station has no usable callsign or locator."
);
return;
}
String ownCallSign = chatPreferences.getStn_loginCallSign();
if (ownCallSign == null || ownCallSign.isBlank()) {
return;
}
int suffixSeparator = ownCallSign.indexOf("-");
if (suffixSeparator > 0) {
ownCallSign = ownCallSign.substring(0, suffixSeparator);
}
String ownLocator =
chatPreferences.getStn_loginLocatorMainCat();
if (ownLocator == null || ownLocator.isBlank()) {
return;
}
String clientIdentifier =
chatPreferences.getAirScout_asClientNameString();
String serverIdentifier =
chatPreferences.getAirScout_asServerNameString();
String bandValue = resolveAirScoutBandValue(remoteChatMember);
if (bandValue == null) {
System.out.println(
"[AirScout, info]: Show-path request ignored because no "
+ "usable propagation frequency could be resolved."
);
return;
}
int port =
chatPreferences.getAirScout_asCommunicationPort();
String query =
"ASSHOWPATH: \""
+ clientIdentifier
+ "\" \""
+ serverIdentifier
+ "\" "
+ bandValue
+ ","
+ ownCallSign
+ ","
+ ownLocator
+ ","
+ remoteChatMember.getCallSign()
+ ","
+ remoteChatMember.getQra()
+ " ";
byte[] payload = query.getBytes(StandardCharsets.UTF_8);
try (
DatagramSocket socket = new DatagramSocket()
) {
socket.setBroadcast(true);
InetAddress address =
InetAddress.getByName("255.255.255.255");
DatagramPacket packet = new DatagramPacket(
payload,
payload.length,
address,
port
);
socket.send(packet);
} catch (IOException exception) {
System.out.println(
"[AirScout, error]: Could not send show-path request: "
+ exception.getMessage()
);
}
}
/**
* Resolves the AirScout protocol frequency for one station. In automatic mode
* this uses the same station-specific resolution as the internal path analysis;
* manual mode keeps the configured forced value for compatibility.
*
* @param remoteChatMember target station
* @return AirScout frequency in 100-Hz units, or {@code null} if auto mode has
* no safe result
*/
public String resolveAirScoutBandValue(ChatMember remoteChatMember) {
if (!chatPreferences.isAirScout_autoBandSelectionEnabled()) {
return chatPreferences.getAirScout_asBandString();
}
if (reachabilityService == null) {
return null;
}
var resolution = reachabilityService
.resolveAutomaticPropagationFrequency(remoteChatMember);
return resolution == null ? null : resolution.getAirScoutBandValue();
}
/**
* starts the calculation scheduler for scores / priorities of skeds to be made
*/
private void startScoreScheduler() {
if (scoreScheduler != null && !scoreScheduler.isShutdown()) return;
scoreScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r);
t.setName("ScoreServiceScheduler");
t.setDaemon(true);
return t;
});
scoreScheduler.scheduleAtFixedRate(() -> {
try {
scoreService.tick();
} catch (Exception e) {
System.err.println("[ChatController] CRITICAL ERROR in ScoreService tick:");
e.printStackTrace();
}
}, 1, 3, TimeUnit.SECONDS);
scoreService.requestRecompute("startup");
System.out.println("[ChatController] ScoreService scheduler started.");
}
private void stopScoreScheduler() {
if (scoreScheduler != null) scoreScheduler.shutdownNow();
scoreScheduler = null;
}
/**
* Handles the disconnect of either the chat (Case DISCONNECTONLY) or the
* complete application life including all threads (case CLOSEALL)<br/><br/>
* Look in ApplicationConstants for the DISCSTRINGS
*
* @param action: "CLOSEALL" or "DISCONNECTONLYCHAT", on application close event
* (Settings Window closed), Disconnect on Disconnect-Button
* clicked (new connection may follow)
*/
public void disconnect(String action) {
/*
* New connections are owned by the session manager. Keep the historic cleanup
* below as a compatibility fallback, but do not let it manipulate resources
* belonging to a replacement session.
*/
if (on4KstConnectionManager != null) {
disconnectManaged(action);
return;
}
// stopContextLoop(); //stops thread for calculating sked priorities
stopScoreScheduler();
stopDxClusterServer();
this.setDisconnectionPerformedByUser(true);
try {
/**
* Kill UCX and Wintest packetreader by sending poison pill to the reader threads
*/
DatagramSocket dsocket;
String host = "255.255.255.255";
int port = chatPreferences.getLogsynch_ucxUDPWkdCallListenerPort();
InetAddress address;
address = InetAddress.getByName("255.255.255.255");
DatagramPacket packet = new DatagramPacket(ApplicationConstants.DISCONNECT_RDR_POISONPILL.getBytes(), ApplicationConstants.DISCONNECT_RDR_POISONPILL.length(), address, port);
DatagramPacket killWintestReaderPacket = new DatagramPacket(ApplicationConstants.DISCONNECT_RDR_POISONPILL.getBytes(), ApplicationConstants.DISCONNECT_RDR_POISONPILL.length(), address, chatPreferences.getLogsynch_wintestNetworkPort());
dsocket = new DatagramSocket();
dsocket.setBroadcast(true);
dsocket.send(packet);
dsocket.close();
dsocket = new DatagramSocket();
dsocket.setBroadcast(true);
dsocket.send(killWintestReaderPacket);
dsocket.close();
readUDPbyUCXThread.interrupt();
stopWintestUdpListener();
} catch (Exception error) {
System.out.println("Chatcrontroller, ERROR: unable to send poison pill to ucxThread");
}
try {
/**
* Kill AS packetreader by sending poison pill to the reader thread
*/
DatagramSocket dsocket;
String host = "255.255.255.255";
int port = chatPreferences.getAirScout_asCommunicationPort();
InetAddress address;
address = InetAddress.getByName("255.255.255.255");
DatagramPacket packet = new DatagramPacket(ApplicationConstants.DISCONNECT_RDR_POISONPILL.getBytes(), ApplicationConstants.DISCONNECT_RDR_POISONPILL.length(), address, port);
dsocket = new DatagramSocket();
dsocket.setBroadcast(true);
dsocket.send(packet);
dsocket.close();
} catch (Exception error) {
System.out.println("Chatcrontroller, ERROR: unable to send poison pill to ucxThread");
}
if (action.equals(ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE)) {
// rotatorClient.
// this.lst_chatMemberList.clear();;
this.clearActiveChatMembers();
this.lst_clusterMemberList.clear();
this.setDisconnected(true);
this.setConnectedAndLoggedIn(false);
this.setConnectedAndNOTLoggedIn(false);
// disconnect telnet and kill all sockets and connections
keepAliveTimer.cancel();
keepAliveTimer.purge();
ChatMessage killThreadPoisonPillMsg = new ChatMessage();
killThreadPoisonPillMsg.setMessageText(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
killThreadPoisonPillMsg.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
messageRXBus.clear();
messageTXBus.clear();
messageRXBus.add(killThreadPoisonPillMsg); //kills messageprocessor
messageTXBus.add(killThreadPoisonPillMsg); //kills writethread
// writeThread.interrupt();
// readThread.interrupt();
stopBeaconTimer();
ASQueryTimer.purge();
ASQueryTimer.cancel();
socketCheckTimer.purge();
socketCheckTimer.cancel();
userActualizationtimer.purge();
userActualizationtimer.cancel();
userActualizationtimer.purge();
userActualizationtimer.cancel();
// consoleReader.interrupt();
messageProcessor.interrupt();
readUDPbyUCXThread.interrupt();
stopWintestUdpListener();
airScoutUDPReaderThread.interrupt();
dbHandler.closeDBConnection();
rotatorClient.stopRotor();
rotatorClient.stop();
try {
if (socket != null) {
socket.close();
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (Exception e2) {
// TODO Auto-generated catch block
e2.printStackTrace();
}
} else if (action.equals(ApplicationConstants.DISCSTRING_DISCONNECTONLY)){
this.clearActiveChatMembers();
this.lst_clusterMemberList.clear();
this.setDisconnected(true);
this.setConnectedAndLoggedIn(false);
this.setConnectedAndNOTLoggedIn(false);
// disconnect telnet and kill all sockets and connections
keepAliveTimer.cancel();
keepAliveTimer.purge();
ChatMessage killThreadPoisonPillMsg = new ChatMessage();
killThreadPoisonPillMsg.setMessageText(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
killThreadPoisonPillMsg.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
messageRXBus.clear();
messageTXBus.clear();
messageRXBus.add(killThreadPoisonPillMsg); //kills messageprocessor
messageTXBus.add(killThreadPoisonPillMsg); //kills writethread
writeThread.interrupt();
readThread.interrupt();
beaconTimer.purge();
beaconTimer.cancel();
ASQueryTimer.purge();
ASQueryTimer.cancel();
socketCheckTimer.purge();
socketCheckTimer.cancel();
userActualizationtimer.purge();
userActualizationtimer.cancel();
// consoleReader.interrupt();
// messageProcessor.interrupt();
readUDPbyUCXThread.interrupt(); //need poisonpill?
stopWintestUdpListener();
airScoutUDPReaderThread.interrupt(); //need poisonpill?
// dbHandler.closeDBConnection();
// this.dbHandler = null;
try {
if (socket != null) {
socket.close();
}
} catch (IOException e) {
e.printStackTrace();
} catch (Exception e2) {
e2.printStackTrace();
}
}
}
private void disconnectManaged(String action) {
setDisconnectionPerformedByUser(true);
on4KstConnectionManager.stopByUser();
clearActiveChatMembers();
runOnFxThread(() -> lst_clusterMemberList.clear());
stopBeaconTimer();
stopScoreScheduler();
stopDxClusterServer();
cancelTimer(userActualizationtimer);
userActualizationtimer = null;
cancelTimer(ASQueryTimer);
ASQueryTimer = null;
stopUdpReader(readUDPbyUCXThread,
chatPreferences.getLogsynch_ucxUDPWkdCallListenerPort());
readUDPbyUCXThread = null;
stopUdpReader(readUDPByWintestThread,
chatPreferences.getLogsynch_wintestNetworkPort());
stopWintestUdpListener();
stopUdpReader(airScoutUDPReaderThread,
chatPreferences.getAirScout_asCommunicationPort());
airScoutUDPReaderThread = null;
if (ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE.equals(action)) {
if (dbHandler != null) {
dbHandler.closeDBConnection();
}
if (rotatorClient != null) {
rotatorClient.stopRotor();
rotatorClient.stop();
rotatorClient = null;
}
}
}
private void cancelTimer(Timer timer) {
if (timer != null) {
timer.cancel();
timer.purge();
}
}
private void stopUdpReader(Thread readerThread, int listenerPort) {
if (readerThread != null) {
readerThread.interrupt();
}
try (DatagramSocket datagramSocket = new DatagramSocket()) {
datagramSocket.setBroadcast(true);
byte[] poison = ApplicationConstants.DISCONNECT_RDR_POISONPILL.getBytes(
StandardCharsets.UTF_8);
DatagramPacket packet = new DatagramPacket(
poison, poison.length,
InetAddress.getByName("255.255.255.255"), listenerPort);
datagramSocket.send(packet);
} catch (IOException exception) {
System.out.println("[ChatController, warning]: could not wake UDP reader on port "
+ listenerPort + ": " + exception.getMessage());
}
}
// private ObservableList<ContestSked> activeSkeds = FXCollections.observableArrayList();
// public ObservableList<ContestSked> getActiveSkeds() {
// return activeSkeds;
// }
// SIGNAL: Ein Property, das wir hochzählen, um der GUI zu sagen "Daten haben sich geändert"
// private LongProperty uiRefreshSignal = new SimpleLongProperty(0);
// public LongProperty uiRefreshSignalProperty() {
// return uiRefreshSignal;
// }
// public void addSked(ContestSked sked) {
// Platform.runLater(() -> {
// this.activeSkeds.add(sked);
// runContextLoopCycle(); // Trigger sofort
// });
// }
// private PriorityCalculator priorityCalculator = new PriorityCalculator();
// private ScheduledExecutorService contextLoopService;
private ObservableList<ContestSked> activeSkeds =
FXCollections.synchronizedObservableList(FXCollections.observableArrayList());
public ObservableList<ContestSked> getActiveSkeds() {
return activeSkeds;
}
/**
* Priority score pipeline (replaces the former 1-second ContextLoop).
*/
private final Map<String, ChatCategory> lastInboundCategoryByCallSignRaw =
new java.util.concurrent.ConcurrentHashMap<>();
private final ScoreService scoreService = new ScoreService(this, new PriorityCalculator(), 15);
private ScheduledExecutorService scoreScheduler;
private final StationMetricsService stationMetricsService = new StationMetricsService();
private final SkedReminderService skedReminderService = new SkedReminderService(this);
public ScoreService getScoreService() {
return scoreService;
}
public void addSked(ContestSked sked) {
Platform.runLater(() -> {
this.activeSkeds.add(sked);
scoreService.requestRecompute("sked-added");
});
// Push sked to Win-Test via UDP if enabled
if (chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) {
pushSkedToWinTest(sked);
}
}
/**
* Pushes a sked to Win-Test via UDP broadcast.
*
* <p>The internal sked is independent from this handover. If no frequency can
* be resolved safely for the selected band, the internal sked remains active,
* but no misleading Win-Test entry is created.</p>
*/
private void pushSkedToWinTest(ContestSked sked) {
new Thread(() -> {
try {
Double frequencyKHz = resolveSkedFrequencyKHz(sked);
if (frequencyKHz == null) {
reportSkippedWinTestSked(
sked,
"no recent or configured QRG matches "
+ sked.getBand().getDisplayLabel()
);
return;
}
String winTestCallsign =
toWinTestSkedCallsign(
sked.getTargetChatCallsign()
);
if (winTestCallsign == null || winTestCallsign.isBlank()) {
reportSkippedWinTestSked(
sked,
"the target callsign could not be converted"
);
return;
}
InetAddress broadcastAddress = InetAddress.getByName(
chatPreferences
.getLogsynch_wintestNetworkBroadcastAddress()
);
int port =
chatPreferences.getLogsynch_wintestNetworkPort();
String stationName =
chatPreferences
.getLogsynch_wintestNetworkStationNameOfKST();
WinTestSkedSender sender = new WinTestSkedSender(
stationName,
broadcastAddress,
port,
this
);
String targetLocator =
resolveSkedTargetLocator(
sked.getTargetCallsign()
);
String notes = "sked via KST4Contest";
if (targetLocator != null
&& !targetLocator.isBlank()
&& sked.getTargetAzimuth() > 0) {
notes = String.format(
"[%s - %.0f°] %s",
targetLocator,
sked.getTargetAzimuth(),
notes
);
} else if (targetLocator != null
&& !targetLocator.isBlank()) {
notes = String.format(
"[%s] %s",
targetLocator,
notes
);
} else if (sked.getTargetAzimuth() > 0) {
notes = String.format(
"[%.0f°] %s",
sked.getTargetAzimuth(),
notes
);
}
String configuredMode =
chatPreferences.getLogsynch_wintestSkedMode();
/*
* Win-Test mode IDs:
* 0 = CW
* 1 = SSB
*
* SSB is the safe fallback for missing or obsolete settings.
* The former AUTO mode was frequency-based and could not produce
* reliable results on every supported VHF, UHF and SHF band.
*/
int winTestMode =
"CW".equalsIgnoreCase(configuredMode)
? 0
: 1;
sender.pushSkedToWinTest(
sked,
winTestCallsign,
frequencyKHz,
notes,
winTestMode
);
} catch (Exception exception) {
String message =
"Error pushing sked to Win-Test: "
+ exception.getMessage();
System.out.println(
"[ChatController] " + message
);
onThreadStatus(
"WT-SkedSend",
new ThreadStateMessage(
"WT-SkedSend",
false,
message,
true
)
);
exception.printStackTrace();
}
}, "WinTestSkedPush").start();
}
/**
* Resolves the frequency used for the Win-Test sked.
*
* <ol>
* <li>Newest QRG detected for the target station on the selected band</li>
* <li>Own QRG belonging to the target's chat category</li>
* <li>No result: do not send the Win-Test sked</li>
* </ol>
*/
private Double resolveSkedFrequencyKHz(ContestSked sked) {
if (sked == null || sked.getBand() == null) {
return null;
}
Double targetFrequencyKHz =
resolveRecentTargetFrequencyKHz(sked);
if (targetFrequencyKHz != null) {
System.out.println(
"[ChatController] SKED frequency from target: "
+ targetFrequencyKHz
+ " kHz on "
+ sked.getBand()
);
return targetFrequencyKHz;
}
String ownQrg =
resolveOwnQrgForSkedCategory(
sked.getTargetChatCategory()
);
Double ownFrequencyKHz =
parseSkedFrequencyKHz(
ownQrg,
sked.getBand()
);
if (ownFrequencyKHz != null) {
System.out.println(
"[ChatController] SKED frequency from own category QRG: "
+ ownFrequencyKHz
+ " kHz on "
+ sked.getBand()
);
return ownFrequencyKHz;
}
return null;
}
/**
* Returns the newest QRG detected on the selected band across all active
* suffix and category variants of the base callsign.
*/
private Double resolveRecentTargetFrequencyKHz(ContestSked sked) {
List<ChatMember> variants =
findActiveChatMembersByRawCall(
sked.getTargetCallsign()
);
long now = System.currentTimeMillis();
long newestTimestamp = Long.MIN_VALUE;
Double newestFrequencyKHz = null;
for (ChatMember member : variants) {
if (member == null || member.getKnownActiveBands() == null) {
continue;
}
ChatMember.ActiveFrequencyInfo frequencyInfo =
member.getKnownActiveBands().get(
sked.getBand()
);
if (frequencyInfo == null
|| frequencyInfo.frequency <= 0.0
|| !sked.getBand().isPlausible(
frequencyInfo.frequency
)) {
continue;
}
long ageMs = now - frequencyInfo.timestampEpoch;
if (ageMs < 0L
|| ageMs > BandOpportunityResolver
.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS) {
continue;
}
if (frequencyInfo.timestampEpoch > newestTimestamp) {
newestTimestamp = frequencyInfo.timestampEpoch;
newestFrequencyKHz =
frequencyInfo.frequency * 1000.0;
}
}
return newestFrequencyKHz;
}
/**
* Returns the own QRG belonging to the chat category in which the target
* station was selected.
*/
private String resolveOwnQrgForSkedCategory(
ChatCategory targetCategory) {
if (sameChatCategory(
targetCategory,
getChatCategorySecondChat())) {
return chatPreferences
.getMYQRGSecondCat()
.get();
}
return chatPreferences
.getMYQRGFirstCat()
.get();
}
private boolean sameChatCategory(ChatCategory first,
ChatCategory second) {
return first != null
&& second != null
&& first.getCategoryNumber()
== second.getCategoryNumber();
}
/**
* Parses the QRG formats used by KST4Contest and Win-Test.
*
* <p>Examples: 144.300, 144.300.03, 144300 and 144300.0.</p>
*/
private Double parseSkedFrequencyKHz(String value,
Band expectedBand) {
if (value == null
|| value.isBlank()
|| expectedBand == null) {
return null;
}
String normalized = value
.trim()
.replace(',', '.')
.replaceAll("\\s+", "");
try {
java.util.regex.Matcher groupedFrequency =
java.util.regex.Pattern.compile(
"^(\\d{2,5})\\.(\\d{3})(?:\\.(\\d{1,2}))?$"
).matcher(normalized);
if (groupedFrequency.matches()) {
double frequencyKHz =
Integer.parseInt(groupedFrequency.group(1))
* 1000.0
+ Integer.parseInt(
groupedFrequency.group(2)
);
String subKHzPart =
groupedFrequency.group(3);
if (subKHzPart != null) {
double subKHz =
Integer.parseInt(subKHzPart);
frequencyKHz +=
subKHzPart.length() == 1
? subKHz / 10.0
: subKHz / 100.0;
}
return expectedBand.isPlausible(
frequencyKHz / 1000.0
) ? frequencyKHz : null;
}
double numericValue =
Double.parseDouble(normalized);
if (expectedBand.isPlausible(numericValue)) {
return numericValue * 1000.0;
}
if (expectedBand.isPlausible(
numericValue / 1000.0)) {
return numericValue;
}
} catch (NumberFormatException ignored) {
// Invalid or unsupported QRG format.
}
return null;
}
/**
* Removes KST dash suffixes while retaining ordinary amateur-radio slash
* notation such as /P, /M or a country prefix.
*
* <p>Examples:
* DN9APW-2 -> DN9APW
* EA5/G8MBI/P-70 -> EA5/G8MBI/P
* DN9APW-2/P -> DN9APW/P</p>
*/
private String toWinTestSkedCallsign(String chatCallsign) {
if (chatCallsign == null || chatCallsign.isBlank()) {
return null;
}
String normalized =
chatCallsign.trim().toUpperCase(Locale.ROOT);
return normalized.replaceAll(
"-[^/]*(?=/|$)",
""
);
}
private void reportSkippedWinTestSked(ContestSked sked,
String reason) {
String target =
sked == null
? "unknown station"
: sked.getTargetChatCallsign();
String message =
"Win-Test sked not sent for "
+ target
+ ": "
+ reason
+ ". The internal KST4Contest sked remains active.";
System.out.println(
"[ChatController] " + message
);
onThreadStatus(
"WT-SkedSend",
new ThreadStateMessage(
"WT-SkedSend",
false,
message,
true
)
);
}
private String resolveSkedTargetLocator(
String targetCallsignRaw) {
if (targetCallsignRaw == null
|| targetCallsignRaw.isBlank()) {
return null;
}
for (ChatMember member
: findActiveChatMembersByRawCall(
targetCallsignRaw)) {
String locator = member.getQra();
if (locator != null && !locator.isBlank()) {
return locator
.trim()
.toUpperCase(Locale.ROOT);
}
}
return null;
}
public StationMetricsService getStationMetricsService() {
return stationMetricsService;
}
public SkedReminderService getSkedReminderService() {
return skedReminderService;
}
/**
* saves the last recognized chat category for a chatmember, for example when we seen a message
* @param callSignRaw
* @param category
*/
public void rememberLastInboundCategory(String callSignRaw, ChatCategory category) {
if (callSignRaw == null || category == null) return;
lastInboundCategoryByCallSignRaw.put(callSignRaw.trim().toUpperCase(), category);
}
public Map<String, ChatCategory> snapshotLastInboundCategoryMap() {
return new HashMap<>(lastInboundCategoryByCallSignRaw);
}
// public List<ChatMember> snapshotChatMembers() {
// synchronized (getLst_chatMemberList()) {
// return new ArrayList<>(getLst_chatMemberList());
// }
// }
public List<ChatMember> snapshotChatMembers() {
return new ArrayList<>(activeChatMembersByCallAndCategory.values());
}
public List<ContestSked> snapshotActiveSkeds() {
synchronized (activeSkeds) {
return new ArrayList<>(activeSkeds);
}
}
public void requestRemoveExpiredSkeds(long nowEpochMs) {
Platform.runLater(() -> {
synchronized (activeSkeds) {
activeSkeds.removeIf(sked -> (nowEpochMs - sked.getSkedTimeEpoch()) > 300_000);
}
});
}
private String userName;
private String password;
private String showedName;
private String qra;
private String chatState;
// private String hostname = "109.90.0.130";
private String hostname;
// private String praktiKSTVersion = "praktiKST 1.0";
private String praktiKSTVersionInfo = "2022-10 - 2022-12\ndeveloped by DO5AMF, Marc\nContact: praktimarc@gmail.com\nDonations via paypal are welcome";
private int port = 23001; // kst4contest.test 4 23001 //TODO: auslagern in Chatprefs
private ReadUDPbyUCXMessageThread readUDPbyUCXThread;
private ReadUDPByWintestThread readUDPByWintestThread;
private WriteThread writeThread;
private ReadThread readThread;
private InputReaderThread consoleReader;
private ChatMember ownChatMemberObject; // Todo: set at startup
private ChatController chatController;
private MessageBusManagementThread messageProcessor;
private ReadUDPbyAirScoutMessageThread airScoutUDPReaderThread;
private DXClusterThreadPooledServer dxClusterServer;
private PlayAudioUtils playAudioUtils = new PlayAudioUtils();
public PlayAudioUtils getPlayAudioUtils() {
return playAudioUtils;
}
private TimerTask userActualizationTask;
private TimerTask keepAliveMessageSenderTask;
private LinkedBlockingQueue<ChatMessage> messageRXBus; // Queue in which all Chatmessages are buffered, sources are
// read- and write-thread
private LinkedBlockingQueue<ChatMessage> messageTXBus; // Queue in which all Chatmessages are buffered, sources are
// read- and write-thread
private String observedSendThisMessageString;
private DBController dbHandler;
private ReachabilityService reachabilityService;
private final WorkedGrossFieldCache workedGrossFieldCache = new WorkedGrossFieldCache();
private Socket socket;
private ServerSocket cluster_telnetServerSocket; // socket that accepts telnet client connects (cluster client)
// private ServerSocketChannel cluster_telnetServerSocketChannel;
private Timer userActualizationtimer;
private Timer keepAliveTimer;
private Timer beaconTimer;
private Timer ASQueryTimer;
private Timer socketCheckTimer;
// ******All abstract types below here are used by the messageprocessor!
// ***************
private ObservableList<ChatMessage> lst_globalChatMessageList = FXCollections.observableArrayList(); //All chatmessages will be put in there, later create filtered message lists
// private ObservableList<ChatMessage> lst_toAllMessageList = FXCollections.observableArrayList(); // directed to all
// (beacon)
private FilteredList<ChatMessage> lst_toAllMessageList = new FilteredList<>(lst_globalChatMessageList); // directed to all
// private ObservableList<ChatMessage> lst_toMeMessageList = FXCollections.observableArrayList(); // directed to my
// call
private FilteredList<ChatMessage> lst_toMeMessageList = new FilteredList<>(lst_globalChatMessageList);
private FilteredList<ChatMessage> lst_selectedCallSignInfofilteredMessageList = new FilteredList<>(lst_globalChatMessageList); // directed to all
// private ObservableList<ChatMessage> lst_toOtherMessageList = FXCollections.observableArrayList(); // directed to a
// call but not
// mine
private FilteredList<ChatMessage> lst_toOtherMessageList = new FilteredList<>(lst_globalChatMessageList);
// private ObservableList<String> lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList();
private ObservableList<String>
lstNotify_QSOSniffer_sniffedCallSignList;
/**
* we do some trick here with the chatmemberlist to not make it neccessary to change all boolean properties if the
* chatmember object to observables. We trigger the list for changes on an object which we change whenever a list
* update will be neccessary to process (important for correct lifetime filtering!)
*/
// private ObservableList<ChatMember> chatMemberList = FXCollections.observableArrayList(workedInfoChange -> new Observable[] {workedInfoChange.workedInfoChangeFireListEventTriggerProperty()}); // List of active stations
// in chat
private ObservableList<ChatMember> chatMemberList = FXCollections.observableArrayList(); // List of active stations
// of active stn in chat
private FilteredList<ChatMember> lst_chatMemberListFiltered = new FilteredList<ChatMember>(chatMemberList);
private SortedList<ChatMember> lst_chatMemberSortedFilteredList = new SortedList<ChatMember>(lst_chatMemberListFiltered);
private ObservableList<ChatMember> lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List
private ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList();
private ObservableList<ClusterMessage> lst_clusterMemberList = FXCollections.observableArrayList();
/*
* Thread-safe active-member model.
*
* MessageBusManagementThread must not use the JavaFX TableView backing list as
* its primary data model. Otherwise every add/remove fires FilteredList,
* SortedList and TableView selection listeners on the MessageBus thread and JavaFX
* can throw "Not on FX application thread" or internal listener NPEs.
*
* This map is the worker-thread safe source of truth. The ObservableList below
* remains a UI mirror and is mutated only on the JavaFX application thread.
*/
private final java.util.concurrent.ConcurrentMap<String, ChatMember> activeChatMembersByCallAndCategory =
new java.util.concurrent.ConcurrentHashMap<>();
/*
* Message table update buffers.
*
* Do not write directly to lst_globalChatMessageList from worker threads.
* Use publishChatMessage(...) instead.
*
* The actual ObservableList mutation is batched and executed on the JavaFX
* application thread. The visible list order remains newest-first.
*/
private final Object pendingChatMessagesLock = new Object();
private final List<ChatMessage> pendingChatMessages = new ArrayList<>();
private boolean chatMessageFlushScheduled = false;
private static final int RECENT_INBOUND_MESSAGE_KEYS_MAX = 5_000;
private final LinkedHashMap<String, Boolean> recentInboundMessageKeys =
new LinkedHashMap<>();
/*
* Same idea for DXCluster messages.
*/
private final Object pendingClusterMessagesLock = new Object();
private final List<ClusterMessage> pendingClusterMessages = new ArrayList<>();
private boolean clusterMessageFlushScheduled = false;
private ObservableList<ChatMember> lst_DBBasedWkdCallSignList = FXCollections.observableArrayList();
// private HashMap<String, ChatMember> map_ucxLogInfoWorkedCalls = new HashMap<String, ChatMember>(); //Destination of ucx-log worked-messages
// ******************************************************************************************************************************************
/**
* Executes UI-bound work on the JavaFX application thread.
*
* Worker threads may call this safely. If the caller is already on the FX
* thread, the action is executed immediately to preserve ordering.
*/
private void runOnFxThread(Runnable action) {
if (action == null) {
return;
}
if (Platform.isFxApplicationThread()) {
action.run();
} else {
Platform.runLater(action);
}
}
/**
* Builds the unique identity key for one active ON4KST login.
*
* <p>The complete callsign must be preserved here. Callsigns such as
* {@code 9A0BB-2}, {@code 9A0BB-70} and {@code 9A0BB-144} represent different
* active chat sessions and must therefore remain separate objects, even when
* they are logged into the same chat category.</p>
*
* <p>The normalized base callsign ({@code callSignRaw}) is deliberately not
* used for this key. It remains the common station key for worked, band and
* NOT-QRV information.</p>
*/
private String buildActiveChatMemberKey(String callSign, ChatCategory category) {
if (callSign == null) {
return null;
}
String fullCallSign = callSign.trim().toUpperCase(Locale.ROOT);
if (fullCallSign.isBlank()) {
return null;
}
int categoryNumber =
category == null ? -1 : category.getCategoryNumber();
return fullCallSign + "|" + categoryNumber;
}
private String buildActiveChatMemberKey(ChatMember member) {
if (member == null) {
return null;
}
return buildActiveChatMemberKey(
member.getCallSign(),
member.getChatCategory()
);
}
/**
* Uses one unambiguous explicit QRG from the current station name as the
* initial compatibility frequency.
*
* <p>Multiple explicit QRGs are valid band evidence, but there is no safe
* assumption which one is the current run frequency. Therefore the legacy
* frequency property is initialized only when exactly one explicit QRG exists.</p>
*/
/* package */ void initializeFrequencyFromStationNameIfUnambiguous(
ChatMember member
) {
if (member == null) {
return;
}
List<FrequencyTextParser.DetectedFrequency> detectedFrequencies =
FrequencyTextParser.findExplicitFrequencies(
member.getName()
);
if (detectedFrequencies.size() != 1) {
return;
}
member.initializeFrequencyIfEmpty(
detectedFrequencies
.get(0)
.getFrequencyMHz()
);
}
/**
* Adds or replaces an active chat member in the worker-thread model and mirrors
* that change to the JavaFX list. This is the only supported path for ON4KST
* user-enter events.
*/
public void addOrUpdateActiveChatMember(ChatMember member) {
String key = buildActiveChatMemberKey(member);
if (key == null) {
return;
}
initializeFrequencyFromStationNameIfUnambiguous(member);
activeChatMembersByCallAndCategory.put(key, member);
runOnFxThread(() -> {
int existingIndex = findChatMemberIndexInUiListByKey(key);
if (existingIndex >= 0) {
lst_chatMemberList.set(existingIndex, member);
} else {
lst_chatMemberList.add(member);
}
});
}
/**
* Removes an active chat member from the worker-thread model and from the UI
* mirror. Removal from the ObservableList is always performed on the FX thread
* because the list is bound to FilteredList/SortedList/TableView.
*/
public boolean removeActiveChatMember(ChatMember member) {
String key = buildActiveChatMemberKey(member);
if (key == null) {
return false;
}
ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key);
runOnFxThread(() -> lst_chatMemberList.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember))));
return removedMember != null;
}
/**
* Clears the active-member model and the JavaFX UI mirror. Use this instead of
* getLst_chatMemberList().clear() from timers or worker threads.
*/
public void clearActiveChatMembers() {
activeChatMembersByCallAndCategory.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);
Map<String, ChatMember> workedDataFromDatabase =
loadWorkedStateForInitialUserList(safeMembers);
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);
if (workedDataFromDatabase != null) {
getLst_DBBasedWkdCallSignList().setAll(
workedDataFromDatabase.values());
}
fireUserListUpdate("Complete ON4KST user list received");
});
}
/**
* Loads one database snapshot for a completed initial ON4KST user list and
* applies it before those members are published to the active model or UI.
* Every callsign variant receives the state stored for its normalized base
* callsign.
*
* @param initialMembers completed members of one chat category
* @return loaded snapshot, or {@code null} when the database read failed
*/
/* package */ Map<String, ChatMember> loadWorkedStateForInitialUserList(
Collection<ChatMember> initialMembers
) {
if (dbHandler == null) {
LOGGER.warning(
"Cannot load Worked state for initial ON4KST user list: "
+ "database is not initialized");
return null;
}
try {
Map<String, ChatMember> workedDataFromDatabase =
dbHandler.fetchChatMemberWkdDataFromDB();
applyWorkedAndQrvStateFromDatabase(
initialMembers, workedDataFromDatabase);
return workedDataFromDatabase;
} catch (SQLException | RuntimeException exception) {
LOGGER.log(
Level.WARNING,
"Could not load Worked state for completed initial ON4KST user list",
exception);
return null;
}
}
/**
* Resolves a member from the thread-safe active model. This avoids reading the
* TableView backing list from MessageBusManagementThread.
*/
public ChatMember findActiveChatMember(ChatMember lookForThis) {
String key = buildActiveChatMemberKey(lookForThis);
return key == null ? null : activeChatMembersByCallAndCategory.get(key);
}
public ChatMember findActiveChatMember(String callSign, ChatCategory category) {
String key = buildActiveChatMemberKey(callSign, category);
return key == null ? null : activeChatMembersByCallAndCategory.get(key);
}
public int getActiveChatMemberCount() {
return activeChatMembersByCallAndCategory.size();
}
/**
* Returns all active category variants of a callsign. This is used when a QRG or
* band hint should be propagated from one category instance to the same station
* in another category.
*/
public List<ChatMember> findActiveChatMembersByRawCall(String callSignRaw) {
String normalizedCallsign = ChatMember.normalizeCallSignToBaseCallSign(callSignRaw);
if (normalizedCallsign == null || normalizedCallsign.isBlank()) {
return Collections.emptyList();
}
String normalizedKeyPart = normalizedCallsign.trim().toUpperCase(Locale.ROOT);
List<ChatMember> matchingMembers = new ArrayList<>();
for (ChatMember member : activeChatMembersByCallAndCategory.values()) {
if (member == null) {
continue;
}
String memberCall = member.getCallSignRaw() != null ? member.getCallSignRaw() : member.getCallSign();
String normalizedMemberCall = ChatMember.normalizeCallSignToBaseCallSign(memberCall);
if (normalizedMemberCall != null && normalizedMemberCall.equalsIgnoreCase(normalizedKeyPart)) {
matchingMembers.add(member);
}
}
return matchingMembers;
}
/**
* Applies a QSO received from an external logger to all active callsign
* variants. Active member objects back JavaFX views, so their state is changed
* only on the JavaFX Application Thread. The band-upgrade hint is evaluated
* afterwards and therefore sees the new Worked state.
*
* @param loggedQso validated external QSO
*/
public void applyExternalLoggedQso(ExternalLoggedQso loggedQso) {
if (loggedQso == null) {
return;
}
runOnFxThread(() -> {
int updatedMembers = markExternalLoggedQsoMembers(
activeChatMembersByCallAndCategory.values(), loggedQso);
if (updatedMembers == 0) {
return;
}
fireUserListUpdate("External logger Worked state updated");
try {
onExternalLogEntryReceived(loggedQso.getCallSign());
} catch (Exception exception) {
LOGGER.log(Level.WARNING,
"Band-upgrade hint failed after external logger update for "
+ loggedQso.getCallSign(), exception);
}
});
}
static int markExternalLoggedQsoMembers(
Collection<ChatMember> members,
ExternalLoggedQso loggedQso
) {
if (members == null || loggedQso == null) {
return 0;
}
String workedBaseCall = ChatMember.normalizeCallSignToBaseCallSign(
loggedQso.getCallSign());
if (workedBaseCall == null || workedBaseCall.isBlank()) {
return 0;
}
int updatedMembers = 0;
for (ChatMember member : members) {
if (member == null) {
continue;
}
String memberCall = member.getCallSignRaw() != null
? member.getCallSignRaw() : member.getCallSign();
String memberBaseCall = ChatMember.normalizeCallSignToBaseCallSign(memberCall);
if (memberBaseCall != null && memberBaseCall.equalsIgnoreCase(workedBaseCall)) {
loggedQso.applyToActiveMember(member);
updatedMembers++;
}
}
return updatedMembers;
}
/**
* Applies the global Worked state from the Simplelogfile to every active
* callsign variant with the same base callsign. UI-backed state is changed only
* on the JavaFX Application Thread.
*
* @param workedBaseCalls normalized callsigns detected in the selected file
*/
public void applySimpleLogWorkedBaseCalls(Set<String> workedBaseCalls) {
Set<String> normalizedWorkedBaseCalls = normalizeWorkedBaseCalls(workedBaseCalls);
if (normalizedWorkedBaseCalls.isEmpty()) {
return;
}
runOnFxThread(() -> {
int changedMembers = markSimpleLogWorkedMembers(
activeChatMembersByCallAndCategory.values(), normalizedWorkedBaseCalls);
int changedClusterMessages = markSimpleLogWorkedClusterMessages(
lst_clusterMemberList, normalizedWorkedBaseCalls);
if (changedMembers > 0) {
fireUserListUpdate("Simplelogfile Worked status updated");
}
LOGGER.log(Level.FINE,
"Simplelogfile marked {0} active members and {1} cluster messages as worked.",
new Object[] { changedMembers, changedClusterMessages });
});
}
static int markSimpleLogWorkedMembers(
Collection<ChatMember> members,
Set<String> workedBaseCalls
) {
if (members == null || workedBaseCalls == null || workedBaseCalls.isEmpty()) {
return 0;
}
int changed = 0;
for (ChatMember member : members) {
if (member == null || member.isWorked()) {
continue;
}
String memberCall = member.getCallSignRaw() != null
? member.getCallSignRaw() : member.getCallSign();
String baseCall = ChatMember.normalizeCallSignToBaseCallSign(memberCall);
if (baseCall != null && workedBaseCalls.contains(baseCall.toUpperCase(Locale.ROOT))) {
member.setWorked(true);
changed++;
}
}
return changed;
}
static int markSimpleLogWorkedClusterMessages(
Collection<ClusterMessage> clusterMessages,
Set<String> workedBaseCalls
) {
if (clusterMessages == null || workedBaseCalls == null || workedBaseCalls.isEmpty()) {
return 0;
}
int changed = 0;
for (ClusterMessage clusterMessage : clusterMessages) {
if (clusterMessage == null || clusterMessage.isReceiverWkd()
|| clusterMessage.getReceiver() == null) {
continue;
}
ChatMember receiver = clusterMessage.getReceiver();
String receiverCall = receiver.getCallSignRaw() != null
? receiver.getCallSignRaw() : receiver.getCallSign();
String baseCall = ChatMember.normalizeCallSignToBaseCallSign(receiverCall);
if (baseCall != null && workedBaseCalls.contains(baseCall.toUpperCase(Locale.ROOT))) {
clusterMessage.setReceiverWkd(true);
changed++;
}
}
return changed;
}
private static Set<String> normalizeWorkedBaseCalls(Set<String> workedBaseCalls) {
if (workedBaseCalls == null || workedBaseCalls.isEmpty()) {
return Set.of();
}
Set<String> normalizedCalls = new HashSet<>();
for (String callSign : workedBaseCalls) {
String baseCall = ChatMember.normalizeCallSignToBaseCallSign(callSign);
if (baseCall != null && !baseCall.isBlank()) {
normalizedCalls.add(baseCall.toUpperCase(Locale.ROOT));
}
}
return Set.copyOf(normalizedCalls);
}
/**
* Notifies the UI after a missing Simplelogfile was created successfully.
*
* @param filePath absolute path of the new file
*/
public void notifySimpleLogFileCreated(Path filePath) {
if (filePath == null || statusListener == null) {
return;
}
runOnFxThread(() -> statusListener.onSimpleLogFileCreated(filePath));
}
/**
* Copies the band-specific NOT-QRV state to every active category variant of the
* same base callsign. The database already uses callSignRaw as its key; applying
* the state to the runtime model immediately prevents category-dependent B+,
* filter, map and score results before the next database refresh.
*
* @param sourceMember member whose current NOT-QRV checkboxes are authoritative
*/
public void propagateNotQrvStateToActiveMembers(ChatMember sourceMember) {
if (sourceMember == null) {
return;
}
String rawCall = sourceMember.getCallSignRaw() != null
? sourceMember.getCallSignRaw()
: sourceMember.getCallSign();
List<ChatMember> variants = findActiveChatMembersByRawCall(rawCall);
if (variants.isEmpty()) {
variants = List.of(sourceMember);
}
for (ChatMember target : variants) {
if (target == null) {
continue;
}
target.setQrv50(sourceMember.isQrv50());
target.setQrv70(sourceMember.isQrv70());
target.setQrv144(sourceMember.isQrv144());
target.setQrv432(sourceMember.isQrv432());
target.setQrv1240(sourceMember.isQrv1240());
target.setQrv2300(sourceMember.isQrv2300());
target.setQrv3400(sourceMember.isQrv3400());
target.setQrv5600(sourceMember.isQrv5600());
target.setQrv10G(sourceMember.isQrv10G());
}
if (scoreService != null) {
scoreService.requestRecompute("NOT-QRV state changed");
}
fireUserListUpdate("NOT-QRV state propagated to callsign variants");
}
/**
* Updates locator and derived direction values in the active model. The UI list
* contains the same member object, but the user-list refresh is still triggered
* on the JavaFX thread so TableView/map derived displays can repaint safely.
*/
public boolean updateActiveChatMemberLocator(ChatMember lookupMember, String newLocator) {
ChatMember activeMember = findActiveChatMember(lookupMember);
if (activeMember == null) {
return false;
}
activeMember.setQra(newLocator);
activeMember.setQrb(new Location().getDistanceKmByTwoLocatorStrings(chatPreferences.getStn_loginLocatorMainCat(), newLocator));
activeMember.setQTFdirection(new Location(chatPreferences.getStn_loginLocatorMainCat()).getBearing(new Location(newLocator)));
fireUserListUpdate("Locator changed");
return true;
}
public boolean updateActiveChatMemberState(ChatMember lookupMember, int newState) {
ChatMember activeMember = findActiveChatMember(lookupMember);
if (activeMember == null) {
return false;
}
activeMember.setState(newState);
fireUserListUpdate("User state changed");
return true;
}
/**
* Applies a UM3-style user-info update only to an already active chat member.
*
* ON4KST may send UM3 messages for stations that are not logged into the
* current channel, for example when a user changes locator/profile data on the
* website. Such stations must not be added to the visible chat member list,
* because they cannot be addressed in the channel. Their full information will
* be delivered again with UA0/UA5/UA2 when they actually join.
*
* @return true if an active member was found and updated, false if the UM3
* information was intentionally ignored.
*/
public boolean updateActiveChatMemberInfoIfPresent(ChatMember updatedMember) {
String key = buildActiveChatMemberKey(updatedMember);
if (key == null) {
return false;
}
ChatMember activeMember = activeChatMembersByCallAndCategory.get(key);
if (activeMember == null) {
return false;
}
activeMember.setName(updatedMember.getName());
initializeFrequencyFromStationNameIfUnambiguous(
activeMember
);
activeMember.setQra(updatedMember.getQra());
activeMember.setState(updatedMember.getState());
activeMember.setLastActivity(updatedMember.getLastActivity());
activeMember.setActivityTimeLastInEpoch(updatedMember.getActivityTimeLastInEpoch());
activeMember.setQrb(updatedMember.getQrb());
activeMember.setQTFdirection(updatedMember.getQTFdirection());
fireUserListUpdate("User info updated");
return true;
}
/**
* Backward-compatible wrapper for older call sites. Despite the historic name,
* this method no longer adds unknown UM3 users to the active member model.
*/
public boolean updateOrAddActiveChatMemberInfo(ChatMember updatedMember) {
return updateActiveChatMemberInfoIfPresent(updatedMember);
}
/**
* Stores a newly detected QRG/band on all active category variants of a station.
* The old StringProperty is still set for compatibility with existing TableView
* columns and DXCluster code.
*/
public void applyDetectedFrequencyToActiveMembers(ChatMember sender, Band detectedBand, double detectedFrequencyMhz) {
if (sender == null || detectedBand == null || detectedFrequencyMhz <= 0) {
return;
}
String displayFrequency = String.valueOf(detectedFrequencyMhz);
String rawCall = sender.getCallSignRaw() != null ? sender.getCallSignRaw() : sender.getCallSign();
for (ChatMember member : findActiveChatMembersByRawCall(rawCall)) {
if (member == null) {
continue;
}
member.addKnownFrequency(detectedBand, detectedFrequencyMhz);
member.setFrequency(new SimpleStringProperty(displayFrequency));
}
// Also cover dummy senders that are not in the active model, e.g. [n/a] senders.
sender.addKnownFrequency(detectedBand, detectedFrequencyMhz);
sender.setFrequency(new SimpleStringProperty(displayFrequency));
fireUserListUpdate("Frequency detected");
}
private int findChatMemberIndexInUiListByKey(String key) {
if (key == null) {
return -1;
}
for (int i = 0; i < lst_chatMemberList.size(); i++) {
if (key.equals(buildActiveChatMemberKey(lst_chatMemberList.get(i)))) {
return i;
}
}
return -1;
}
/**
* checks if the callsign-String of a given chatmember instance and a given list
* instance is in the list (multiple entries are possible to find by this method! <br/>
* If yes, returns an Array of int with the list indexes <b>if not, returns empty array</b>
* <br/>
* <br/>Also gives back indexes for callsign-70 or callsign-2 etc.<br/>
*
* @param lookForThis
* @return int[]
*/
public ArrayList<Integer> checkListForChatMemberIndexesByCallSign(ChatMember lookForThis) {
ArrayList<Integer> resultingIndexes = new ArrayList<Integer>();
if (lookForThis == null) {
// System.out.println("[ChatCtrl] ERROR: null Value for Chatmember detected! Member cannot be in the list!");
return resultingIndexes;
} else if (lookForThis.getCallSignRaw() == null) {
System.out.println("[ChatCtrl] ERROR: null Value in Callsign detected! Member cannot be in the list!");
return resultingIndexes;
}
for (Iterator iterator = lst_chatMemberList.iterator(); iterator.hasNext();) {
ChatMember chatMember = (ChatMember) iterator.next();
if (chatMember.getCallSignRaw().equals(lookForThis.getCallSignRaw())) { //Change for stations with -2 or -70 in logincallsign
// System.out
// .println("chtctrlr: Found raw " + chatMember.getCallSignRaw() + " // " + lookForThis.getCallSign());
resultingIndexes.add(lst_chatMemberList.indexOf(chatMember));
} else {
}
}
return resultingIndexes;
}
/**
* Notifies the UI that station-list derived values have changed.
*
* <p>This method may be called from parser threads, UDP listener threads and
* reachability worker threads. Therefore the listener is always invoked on the
* JavaFX application thread.</p>
*
* @param reason short debug reason for the UI log
*/
public void fireUserListUpdate(String reason) {
if (statusListener == null) {
return;
}
if (Platform.isFxApplicationThread()) {
statusListener.onUserListUpdated(reason);
} else {
Platform.runLater(() -> statusListener.onUserListUpdated(reason));
}
}
// /**
// * checks if the callsign-String of a given chatmember instance and a given list
// * instance is in the list. If yes, returns the index in the List, <b>if not,
// * returns -1.</b>
// *
// * @param lookForThis
// * @return Integer (index), -1 for not found
// */
// public int checkListForChatMemberIndexByCallSign(ChatMember lookForThis) {
//
// if (lookForThis == null) {
//
//// System.out.println("[ChatCtrl] ERROR: null Value for Chatmember detected! Member cannot be in the list!");
// return -1;
// } else if (lookForThis.getCallSign() == null) {
// System.out.println("[ChatCtrl] ERROR: null Value in Callsign detected! Member cannot be in the list!");
// return -1;
// }
//
// for (Iterator iterator = lst_chatMemberList.iterator(); iterator.hasNext();) {
// ChatMember chatMember = (ChatMember) iterator.next();
//// if (chatMember.getCallSign().equals(lookForThis.getCallSign())) {
// if (chatMember.getCallSignRaw().equals(lookForThis.getCallSignRaw())) { //TODO: Change for stations with -2 or -70 in logincallsign
//// System.out
//// .println("chtctrlr: Found raw " + chatMember.getCallSignRaw() + " // " + lookForThis.getCallSign());
//
// return lst_chatMemberList.indexOf(chatMember);
// } else {
//
// }
// }
/**
* At this point we know, the callsign is not active in the chat.
*/
// System.out
// .println("[ChatCtrl, ERROR:] ChecklistForChatMemberIndexByCallsign: " + lookForThis.getCallSign() + "\n" + "List: ");
// for (Iterator iterator = lst_chatMemberList.iterator(); iterator.hasNext();) {
// ChatMember chatMember = (ChatMember) iterator.next();
// System.out.println(lst_chatMemberList.indexOf(lookForThis) + ": " + chatMember.getCallSign());
// }
// return -1;
// }
public FilteredList<ChatMessage> getLst_selectedCallSignInfofilteredMessageList() {
return lst_selectedCallSignInfofilteredMessageList;
}
public void setLst_selectedCallSignInfofilteredMessageList(FilteredList<ChatMessage> lst_selectedCallSignInfofilteredMessageList) {
this.lst_selectedCallSignInfofilteredMessageList = lst_selectedCallSignInfofilteredMessageList;
}
public ObservableList<ChatMessage> getLst_globalChatMessageList() {
return lst_globalChatMessageList;
}
/**
* Adds a chat message to the UI message store.
*
* Important:
* - This method may be called from worker threads.
* - The ObservableList is modified only on the JavaFX application thread.
* - The backing list remains newest-first.
* - Old messages are trimmed to avoid unlimited memory growth.
*/
public void publishChatMessage(ChatMessage message) {
if (message == null) {
return;
}
if (isDuplicateInboundMessage(message)) {
return;
}
synchronized (pendingChatMessagesLock) {
pendingChatMessages.add(message);
if (chatMessageFlushScheduled) {
return;
}
chatMessageFlushScheduled = true;
}
Platform.runLater(this::flushPendingChatMessagesToUi);
}
/**
* Suppresses the small replay overlap deliberately requested after a reconnect.
*
* <p>The manager asks for messages beginning one timestamp before the last known
* message so that a boundary message cannot be lost. This bounded key cache
* removes the expected duplicate without growing for the lifetime of the
* application.</p>
*/
private boolean isDuplicateInboundMessage(ChatMessage message) {
String timestamp = message.getMessageGeneratedTime();
if (timestamp == null || timestamp.isBlank()) {
return false;
}
String sender = message.getSender() == null
? "" : String.valueOf(message.getSender().getCallSign());
String receiver = message.getReceiver() == null
? "" : String.valueOf(message.getReceiver().getCallSign());
int category = message.getChatCategory() == null
? -1 : message.getChatCategory().getCategoryNumber();
String key = category + "|" + timestamp + "|" + sender + "|"
+ receiver + "|" + String.valueOf(message.getMessageText());
synchronized (recentInboundMessageKeys) {
if (recentInboundMessageKeys.containsKey(key)) {
return true;
}
recentInboundMessageKeys.put(key, Boolean.TRUE);
while (recentInboundMessageKeys.size()
> RECENT_INBOUND_MESSAGE_KEYS_MAX) {
Iterator<String> iterator = recentInboundMessageKeys.keySet().iterator();
if (!iterator.hasNext()) {
break;
}
iterator.next();
iterator.remove();
}
return false;
}
}
private void flushPendingChatMessagesToUi() {
List<ChatMessage> batch;
synchronized (pendingChatMessagesLock) {
batch = new ArrayList<>(pendingChatMessages);
pendingChatMessages.clear();
chatMessageFlushScheduled = false;
}
if (batch.isEmpty()) {
return;
}
/*
* pendingChatMessages is collected in arrival order:
* old -> new
*
* lst_globalChatMessageList must stay newest-first:
* new -> old
*/
Collections.reverse(batch);
lst_globalChatMessageList.addAll(0, batch);
trimGlobalChatMessageListIfNeeded();
}
private void trimGlobalChatMessageListIfNeeded() {
int maxSize = ApplicationConstants.CHAT_MESSAGE_STORE_MAX_SIZE;
int trimToSize = ApplicationConstants.CHAT_MESSAGE_STORE_TRIM_TO_SIZE;
if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) {
return;
}
int currentSize = lst_globalChatMessageList.size();
if (currentSize <= maxSize) {
return;
}
/*
* List order is newest-first.
* Therefore old messages are at the end of the list.
*/
lst_globalChatMessageList.remove(trimToSize, currentSize);
}
/**
* Adds a DXCluster message to the UI cluster message store.
*
* Same policy as for chat messages:
* - batched UI update
* - JavaFX thread only for ObservableList mutation
* - newest-first visible order
* - bounded list size
*/
public void publishClusterMessage(ClusterMessage message) {
if (message == null) {
return;
}
synchronized (pendingClusterMessagesLock) {
pendingClusterMessages.add(message);
if (clusterMessageFlushScheduled) {
return;
}
clusterMessageFlushScheduled = true;
}
Platform.runLater(this::flushPendingClusterMessagesToUi);
}
private void flushPendingClusterMessagesToUi() {
List<ClusterMessage> batch;
synchronized (pendingClusterMessagesLock) {
batch = new ArrayList<>(pendingClusterMessages);
pendingClusterMessages.clear();
clusterMessageFlushScheduled = false;
}
if (batch.isEmpty()) {
return;
}
Collections.reverse(batch);
lst_clusterMemberList.addAll(0, batch);
trimClusterMessageListIfNeeded();
}
private void trimClusterMessageListIfNeeded() {
int maxSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_MAX_SIZE;
int trimToSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE;
if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) {
return;
}
int currentSize = lst_clusterMemberList.size();
if (currentSize <= maxSize) {
return;
}
lst_clusterMemberList.remove(trimToSize, currentSize);
}
public void setLst_globalChatMessageList(ObservableList<ChatMessage> lst_globalChatMessageList) {
this.lst_globalChatMessageList = lst_globalChatMessageList;
}
public String getHostname() {
return hostname;
}
public void setHostname(String hostname) {
this.hostname = hostname;
}
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public Socket getSocket() {
return socket;
}
public void setSocket(Socket socket) {
this.socket = socket;
}
public void setMessageTXBus(LinkedBlockingQueue<ChatMessage> messageTXBus) {
this.messageTXBus = messageTXBus;
}
// public String getPraktiKSTVersion() {
// return praktiKSTVersion;
// }
// public void setPraktiKSTVersion(String praktiKSTVersion) {
// this.praktiKSTVersion = praktiKSTVersion;
// }
public String getPraktiKSTVersionInfo() {
return praktiKSTVersionInfo;
}
public void setPraktiKSTVersionInfo(String praktiKSTVersionInfo) {
this.praktiKSTVersionInfo = praktiKSTVersionInfo;
}
// public HashMap getMap_ucxLogInfoWorkedCalls() {
// return map_ucxLogInfoWorkedCalls;
// }
// public void setMap_ucxLogInfoWorkedCalls(HashMap map_ucxLogInfoWorkedCalls) {
// this.map_ucxLogInfoWorkedCalls = map_ucxLogInfoWorkedCalls;
// }
public ObservableList<ChatMember> getLst_chatMemberList() {
return lst_chatMemberList;
}
public ObservableList<ChatMember> getLst_DBBasedWkdCallSignList() {
return lst_DBBasedWkdCallSignList;
}
public void setLst_DBBasedWkdCallSignList(ObservableList<ChatMember> lst_DBBasedWkdCallSignList) {
this.lst_DBBasedWkdCallSignList = lst_DBBasedWkdCallSignList;
}
public void setLst_chatMemberList(ObservableList<ChatMember> lst_chatMemberList) {
this.lst_chatMemberList = lst_chatMemberList;
}
public FilteredList<ChatMember> getLst_chatMemberListFiltered() {
return lst_chatMemberListFiltered;
}
public SortedList<ChatMember> getLst_chatMemberSortedFilteredList() {
return lst_chatMemberSortedFilteredList;
}
public ObservableList<Predicate<ChatMember>> getLst_chatMemberListFilterPredicates() {
return lst_chatMemberListFilterPredicates;
}
public void setLst_chatMemberListFilterPredicates(ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates) {
this.lst_chatMemberListFilterPredicates = lst_chatMemberListFilterPredicates;
}
public ObservableList<ClusterMessage> getLst_clusterMemberList() {
return lst_clusterMemberList;
}
public void setLst_clusterMemberList(ObservableList<ClusterMessage> lst_clusterMemberList) {
this.lst_clusterMemberList = lst_clusterMemberList;
}
public ObservableList<ChatMessage> getLst_toAllMessageList() {
return lst_toAllMessageList;
}
// public void setLst_toAllMessageList(ObservableList<ChatMessage> lst_toAllMessageList) {
// this.lst_toAllMessageList = lst_toAllMessageList;
// }
public void setLst_toAllMessageList(FilteredList<ChatMessage> lst_toAllMessageList) {
this.lst_toAllMessageList = lst_toAllMessageList;
}
public ObservableList<ChatMessage> getLst_toMeMessageList() {
return lst_toMeMessageList;
}
// public void setLst_toMeMessageList(ObservableList<ChatMessage> lst_toMeMessageList) {
// this.lst_toMeMessageList = lst_toMeMessageList;
// }
public void setLst_toMeMessageList(FilteredList<ChatMessage> lst_toMeMessageList) {
this.lst_toMeMessageList = lst_toMeMessageList;
}
public ObservableList<ChatMessage> getLst_toOtherMessageList() {
return lst_toOtherMessageList;
}
// public void setLst_toOtherMessageList(ObservableList<ChatMessage> lst_toOtherMessageList) {
// this.lst_toOtherMessageList = lst_toOtherMessageList;
// }
public void setLst_toOtherMessageList(FilteredList<ChatMessage> lst_toOtherMessageList) {
this.lst_toOtherMessageList = lst_toOtherMessageList;
}
public LinkedBlockingQueue<ChatMessage> getMessageTXBus() {
return messageTXBus;
}
public ChatController() {
super();
chatCategoryMain = new ChatCategory(2);//Todo: selectable chatcategory, switched by user
chatCategorySecondChat = new ChatCategory(3); //Todo: selectable chatcategory, switched by user
ownChatMemberObject = new ChatMember();
ownChatMemberObject.setCallSign(userName);
ownChatMemberObject.setName(showedName);
ownChatMemberObject.setQra(qra);
// this.category = ChatCategory.VUHF;
this.userName = ownChatMemberObject.getName();
// this.hostname = "www.on4kst.org";
this.port = port;
}
/**
* This constructor is used by the Main()-Class of the praktiKST javaFX-gui.
*
*
* @param setOwnChatMemberObject
*/
public ChatController(ChatMember setOwnChatMemberObject,StatusUpdateListener listener) {
super();
chatPreferences = new ChatPreferences();
chatPreferences.readPreferencesFromXmlFile();
// this.statusListener = listener;
lstNotify_QSOSniffer_sniffedCallSignList =
chatPreferences
.getLstNotify_QSOSniffer_sniffedCallSignList();
String dnsFromPrefs = chatPreferences.getStn_on4kstServersDns();
if (dnsFromPrefs != null && !dnsFromPrefs.isEmpty()) {
this.hostname = dnsFromPrefs;
} else {
this.hostname = "109.90.0.130";
}
UpdateChecker checkForUpdates = new UpdateChecker(this);
if (checkForUpdates.downloadLatestVersionInfoXML()) {
updateInformation = checkForUpdates.parseUpdateXMLFile();
};
initLst_toMeMessageList();
lst_toAllMessageList.setPredicate(new Predicate<ChatMessage>() {
@Override
public boolean test(ChatMessage chatMessage) {
try {
if (chatMessage.getReceiver().getCallSign().equals("ALL")) { //TODO: ALL have to be an application-constant
return true;
} else return false;
}
catch (Exception nullPointerExc) {
nullPointerExc.printStackTrace();
System.out.println("ChatController, ERROR: maybe the receiver was null, mostly like a cq message!");
return true;
}
}
});
lst_toOtherMessageList.setPredicate(new Predicate<ChatMessage>() {
@Override
public boolean test(ChatMessage chatMessage) {
try {
if ((!chatMessage.getSender().getCallSign().equals(getChatPreferences().getStn_loginCallSign())) &&
(!chatMessage.getReceiver().getCallSign().equals(getChatPreferences().getStn_loginCallSign())) && (!chatMessage.getReceiver().getCallSign().equals("ALL")) )
//RX not own callsign and TX not own callsign and callsign is not "ALL" (that means, directed to public)
{
return true;
} else return false;
} catch (Exception nullPointerExc) {
// nullPointerExc.printStackTrace();
System.out.println("ChatController, <<<catched ERROR>>>: maybe the receiver was null!");
return false;
}
}
});
dbHandler = new DBController();
reachabilityService = new ReachabilityService(this);
rebuildWorkedGrossFieldCacheFromDatabase();
// chatPreferences = new ChatPreferences();
// chatPreferences.readPreferencesFromXmlFile(); // set the praktikst Prefs by file or default if file is corrupted
chatCategoryMain = chatPreferences.getLoginChatCategoryMain();
chatCategorySecondChat = chatPreferences.getLoginChatCategorySecond();
this.userName = chatPreferences.getStn_loginCallSign();
this.password = chatPreferences.getStn_loginPassword();
// category = setCategory;
ownChatMemberObject = setOwnChatMemberObject;
// this.userName = ownChatMemberObject.getName();
// this.password = ownChatMemberObject.getPassword();
this.hostname = this.getChatPreferences().getStn_on4kstServersDns(); //default: www.on4kst.org
}
private void initLst_toMeMessageList() {
// ObservableList<String> sniffedList = chatPreferences.getLstNotify_QSOSniffer_sniffedCallSignList();
Predicate<ChatMessage> chatFilterPredicate = chatMessage -> {
// Sicherheits-Checks gegen NullPointer (statt try-catch)
if (chatMessage == null || chatMessage.getSender() == null || chatMessage.getReceiver() == null) {
return false;
}
String myCallSign = getChatPreferences().getStn_loginCallSign();
String senderCall = chatMessage.getSender().getCallSign();
String receiverCall = chatMessage.getReceiver().getCallSign();
String msgText = chatMessage.getMessageText();
// --- NEUE LOGIK: Sniffer Liste prüfen ---
// Wenn Absender ODER Empfänger in der Beobachtungsliste stehen -> Anzeigen
// if ((lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) ||
// lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall)) &&
// (!receiverCall.equals(this.getChatPreferences().getStn_loginCallSignRaw()))) {
//
// msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText;
// chatMessage.setMessageText(msgText);
// return true;
// }
if (isSniffedMessage(chatMessage)) {
return true;
}
// --- BESTEHENDE LOGIK ---
// 1. Nachrichten direkt an dich
if (receiverCall.equals(myCallSign)) {
return true;
}
// 2. Deine eigenen Nachrichten (außer an ALL)
if (senderCall.equals(myCallSign) && !receiverCall.equals("ALL")) {
return true;
}
// 3. Mentions im Text (jemand schreibt über dich)
// Nur prüfen, wenn Text nicht null ist und du nicht selbst der Absender bist
if (msgText != null && !senderCall.equals(myCallSign)) {
// containsIgnoreCase Logik (etwas robuster als deine Variante)
if (msgText.toLowerCase().contains(myCallSign.toLowerCase())) {
return true;
}
}
return false;
};
lstNotify_QSOSniffer_sniffedCallSignList.addListener((ListChangeListener<String>) c -> {
// System.out.println(c.toString());
// Wir zwingen die FilteredList zum Neuscannen, indem wir das Prädikat neu setzen.
lst_toMeMessageList.setPredicate(null); // kurz resetten (manchmal nötig in älteren JavaFX Versionen)
lst_toMeMessageList.setPredicate(chatFilterPredicate);
});
// lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB");
lst_toMeMessageList.setPredicate(chatFilterPredicate); //sniffed callsign filter predicate is here!
// lst_toMeMessageList.setPredicate(new Predicate<ChatMessage>() {
// @Override
// public boolean test(ChatMessage chatMessage) {
//
// try {
//
// if (chatMessage.getReceiver().getCallSign().equals(getChatPreferences().getStn_loginCallSign())) {
// return true; //messages addressed to you
// }
// if ((chatMessage.getSender().getCallSign().equals(getChatPreferences().getStn_loginCallSign())) && (!chatMessage.getReceiver().getCallSign().equals("ALL"))){
// return true; //your own echo except texts to all (CQ)
// }
//
// String ignoreCaseString = chatMessage.getMessageText();
//
// if ((chatMessage.getMessageText().contains(chatPreferences.getStn_loginCallSign().toLowerCase()) || (chatMessage.getMessageText().contains(chatPreferences.getStn_loginCallSign().toUpperCase())))
// && (!chatMessage.getSender().getCallSign().equals(getChatPreferences().getStn_loginCallSign()))) {
// return true; //if someone writes about you, you will get the mail, too, except you are the sender...!
// }
//
// else {
// return false;
// }
// }
// catch (Exception nullPointerExc) {
// nullPointerExc.printStackTrace();
// System.out.println("ChatController, <<<catched ERROR>>>: maybe the receiver was null, message received b4 user entered chatmessage...!" + nullPointerExc.getMessage());
// return false;
// }
// }
// });
}
/**
* starts wintest udp listener thread
*/
public synchronized void startWintestUdpListener() {
if (readUDPByWintestThread != null && readUDPByWintestThread.isAlive()) {
return;
}
readUDPByWintestThread = new ReadUDPByWintestThread(this, this);
readUDPByWintestThread.setName("readUDPByWintestThread");
readUDPByWintestThread.start();
System.out.println("[ChatController] Win-Test UDP listener started.");
}
/**
* stops wintest udp listener thread
*/
public synchronized void stopWintestUdpListener() {
if (readUDPByWintestThread == null) return;
try {
readUDPByWintestThread.interrupt();
} catch (Exception ignored) { }
readUDPByWintestThread = null;
System.out.println("[ChatController] Win-Test UDP listener stopped.");
}
/**
* restarts wintest udp listener thread
*/
public synchronized void restartWintestUdpListenerIfEnabled() {
stopWintestUdpListener();
if (chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) {
startWintestUdpListener();
}
}
public ObservableList<String> getLstNotify_QSOSniffer_sniffedCallSignList() {
return lstNotify_QSOSniffer_sniffedCallSignList;
}
public void setLstNotify_QSOSniffer_sniffedCallSignList(ObservableList<String> lstNotify_QSOSniffer_sniffedCallSignList) {
this.lstNotify_QSOSniffer_sniffedCallSignList = lstNotify_QSOSniffer_sniffedCallSignList;
}
public ChatPreferences getChatPreferences() {
return chatPreferences;
}
public void setChatPreferences(ChatPreferences chatPreferences) {
this.chatPreferences = chatPreferences;
}
public ChatMember getownChatMemberObject() {
return ownChatMemberObject;
}
public void setOwnCall(ChatMember ownCall) {
this.ownChatMemberObject = ownCall;
}
public LinkedBlockingQueue<ChatMessage> getMessageRXBus() {
return messageRXBus;
}
public void setMessageRXBus(LinkedBlockingQueue<ChatMessage> messageBus) {
this.messageRXBus = messageBus;
}
public WriteThread getWriteThread() {
return writeThread;
}
public void setWriteThread(WriteThread writeThread) {
this.writeThread = writeThread;
}
public ReadThread getReadThread() {
return readThread;
}
public void setReadThread(ReadThread readThread) {
this.readThread = readThread;
}
public ChatCategory getChatCategoryMain() {
return chatCategoryMain;
}
public void setChatCategoryMain(ChatCategory chatCategoryMain) {
this.chatCategoryMain = chatCategoryMain;
}
public DXClusterThreadPooledServer getDxClusterServer() {
return dxClusterServer;
}
public synchronized void startDxClusterServerIfEnabled() {
if (!chatPreferences.isNotify_dxClusterServerEnabled()
|| dxClusterServer != null) {
return;
}
dxClusterServer = new DXClusterThreadPooledServer(
chatPreferences.getNotify_dxclusterServerPort(),
this,
this
);
Thread serverThread = new Thread(dxClusterServer);
serverThread.setName("DXCluster-thread-pooled-server");
serverThread.setDaemon(true);
serverThread.start();
}
public synchronized void stopDxClusterServer() {
DXClusterThreadPooledServer serverToStop = dxClusterServer;
dxClusterServer = null;
if (serverToStop != null) {
serverToStop.stop();
}
}
public synchronized void restartDxClusterServerIfEnabled() {
stopDxClusterServer();
startDxClusterServerIfEnabled();
}
// public void setChatMemberTable(Hashtable<String, ChatMember> chatMemberTable) {
// this.chatMemberTable = chatMemberTable;
// }
//
// public void setChatMemberTable(HashMap<String, ChatMember> chatMemberTable) {
// this.chatMemberTable = chatMemberTable;
// }
public DBController getDbHandler() {
return dbHandler;
}
public void setDbHandler(DBController dbHandler) {
this.dbHandler = dbHandler;
}
/**
* Validates a configured beacon template before it is stored.
*
* <p>The template itself must be a non-empty, protocol-safe message. Global
* variables are resolved as far as their values are currently available.
* A template which temporarily resolves to an empty string, for example
* {@code MYQRG} before a frequency is known, remains valid. The timer performs
* the stricter final validation immediately before transmission.</p>
*
* @param configuredText configured beacon template
* @throws IllegalArgumentException if the template contains invalid protocol
* content or currently resolves to more than
* {@value MAX_BEACON_TEXT_LENGTH} characters
*/
public void validateBeaconTemplate(String configuredText) {
String normalizedTemplate =
On4KstProtocol.messageText(configuredText);
String resolvedText =
new MessageVariableResolver(chatPreferences)
.resolveGlobalVariables(normalizedTemplate);
/*
* A template consisting only of a variable such as MYQRG may temporarily
* resolve to an empty value. It can still be stored because the value may
* become available through TRX synchronisation before the timer runs.
*/
if (resolvedText == null || resolvedText.isBlank()) {
return;
}
validateResolvedBeaconText(resolvedText);
}
/**
* Resolves and validates the final beacon text immediately before transmission.
*
* <p>This is deliberately stricter than {@link #validateBeaconTemplate(String)}.
* A timer run must never queue an empty message merely because a dynamic value
* is not available at that moment.</p>
*
* @param configuredText configured beacon template
* @return normalized text which may safely pass through the regular ON4KST
* message pipeline
* @throws IllegalArgumentException if the resolved message is empty, too long
* or contains an ON4KST delimiter
*/
public String resolveAndValidateBeaconText(String configuredText) {
String resolvedText =
new MessageVariableResolver(chatPreferences)
.resolveGlobalVariables(configuredText);
return validateResolvedBeaconText(resolvedText);
}
/**
* Applies the message-text and length rules to a fully resolved beacon.
*/
private String validateResolvedBeaconText(String resolvedText) {
String normalizedText =
On4KstProtocol.messageText(resolvedText);
if (normalizedText.length() > MAX_BEACON_TEXT_LENGTH) {
throw new IllegalArgumentException(
"The resolved beacon message contains "
+ normalizedText.length()
+ " characters; maximum is "
+ MAX_BEACON_TEXT_LENGTH
+ "."
);
}
return normalizedText;
}
/**
* Starts the shared beacon timer with the interval currently stored in the
* preferences.
*
* <p>Both chat categories deliberately use the same timer. The task checks the
* individual enable flag and text for each category every time it runs.</p>
*
* @param initialDelayMillis delay before the next beacon run
*/
private synchronized void scheduleBeaconTimer(long initialDelayMillis) {
stopBeaconTimer();
int configuredInterval =
chatPreferences.getBcn_beaconIntervalInMinutesMainCat();
int effectiveInterval =
Math.max(MIN_BEACON_INTERVAL_MINUTES, configuredInterval);
/*
* Keep both legacy XML values synchronized. The second value remains in the
* configuration for backward compatibility, but no longer represents an
* independent timer.
*/
chatPreferences.setBcn_beaconIntervalInMinutesMainCat(effectiveInterval);
chatPreferences.setBcn_beaconIntervalInMinutesSecondCat(effectiveInterval);
long intervalMillis = TimeUnit.MINUTES.toMillis(effectiveInterval);
long safeInitialDelay = Math.max(0L, initialDelayMillis);
beaconTimer = new Timer("BeaconTimer");
beaconTimer.schedule(
new BeaconTask(this, this),
safeInitialDelay,
intervalMillis
);
System.out.println("[ChatController, Info]: Shared beacon timer scheduled every "
+ effectiveInterval + " minute(s).");
}
/**
* Applies a changed beacon interval while the chat connection is running.
*
* <p>The countdown starts again with the new interval. No beacon is sent
* immediately merely because the setting was changed.</p>
*/
public synchronized void restartBeaconTimer() {
if (!isConnectedAndLoggedIn()) {
return;
}
long intervalMillis = TimeUnit.MINUTES.toMillis(
Math.max(
MIN_BEACON_INTERVAL_MINUTES,
chatPreferences.getBcn_beaconIntervalInMinutesMainCat()
)
);
scheduleBeaconTimer(intervalMillis);
}
/**
* Stops the shared beacon timer if it is currently running.
*/
private synchronized void stopBeaconTimer() {
if (beaconTimer == null) {
return;
}
beaconTimer.cancel();
beaconTimer.purge();
beaconTimer = null;
}
/**
* execute is the main entry point where the application starts.
* @throws InterruptedException
* @throws IOException
*/
public void execute() throws InterruptedException, IOException {
if (on4KstConnectionManager != null) {
executeManaged();
return;
}
chatController = this;
// ApplicationConstants constants = new ApplicationConstants();
// This block constructs a sample message
// ChatMessage Test = new ChatMessage();
// Test.setMessage("kst4contest.test");
// Test.setMessageDirectedToCommunity(false);
// Test.setMessageGeneratedTime(new Utils4KST().time_convertEpochToReadable("1664669836"));
// Test.setMessageSenderName("marc");
// Test.setMessageText("test2");
// Test.setSender(ownChatMemberObject);
// getLst_toAllMessageList().add(Test);
try {
setDisconnectionPerformedByUser(false);
startScoreScheduler();
//runs sked priority thread
messageRXBus = new LinkedBlockingQueue<ChatMessage>();
messageTXBus = new LinkedBlockingQueue<ChatMessage>();
// socket = new Socket(hostname, port);//socket for the on4kst chat server
socket = new Socket(
chatPreferences.getStn_on4kstServersDns(),
chatPreferences.getStn_on4kstServersPort()
);//socket for the on4kst chat server
System.out.println("Connected to the chat server: " + socket.isConnected());
ByteBuffer buffer = ByteBuffer.allocate(1024);
Selector selector = Selector.open();
// consoleReader = new InputReaderThread(this);
// consoleReader.start();
readThread = new ReadThread(socket, this);
readThread.setName("ReadThread-telnetreader");
readThread.start();
writeThread = new WriteThread(socket, this);
writeThread.setName("Writethread-telnetwriter");
writeThread.start();
readUDPbyUCXThread = new ReadUDPbyUCXMessageThread(chatPreferences.getLogsynch_ucxUDPWkdCallListenerPort(), this, this);
readUDPbyUCXThread.setName("readUDPbyUCXThread");
readUDPbyUCXThread.start();
if (chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) {
startWintestUdpListener();
} else {
System.out.println("[ChatController] Win-Test listener disabled by preference -> not starting.");
}
messageProcessor = new MessageBusManagementThread(this, this);
messageProcessor.setName("messagebusManagementThread");
messageProcessor.start();
airScoutUDPReaderThread = new ReadUDPbyAirScoutMessageThread(
chatPreferences.getAirScout_asCommunicationPort(),
this,
this
);
airScoutUDPReaderThread.setName("airscoutudpreaderThread");
airScoutUDPReaderThread.start();
userActualizationtimer = new Timer();
userActualizationtimer.schedule(new UserActualizationTask(this), 4000, 60000);// TODO: Temporary userlistoutput known qrgs
keepAliveTimer = new Timer();
keepAliveTimer.schedule(new keepAliveMessageSenderTask(this), 4000, 60000);//
if (chatPreferences.isStn_pstRotatorEnabled()) {
initRotor();
} else {
System.out.println("[ChatController, info]: PSTRotator disabled by user preference -> not starting rotator client.");
}
/**
* DX cluster service running config
*/
startDxClusterServerIfEnabled();
this.setConnectedAndLoggedIn(true);
/**
* The CQ-beacon-Task will be executed every time but checks for itself whether
* CQ messages are enabled or not
*/
scheduleBeaconTimer(INITIAL_BEACON_DELAY_MILLIS);
/**
* The AS querier task will be executed every time but checks for itself whether
* AS usage is enabled or not
*/
// Timer ASQueryTimer;
ASQueryTimer = new Timer();
ASQueryTimer.schedule(new AirScoutPeriodicalAPReflectionInquirerTask(this), 10000, 60000);
// 60000 * intervalInMinutes = IntervalInMillis
/**
* Check if socket works
*/
// Timer socketCheckTimer;
socketCheckTimer = new Timer();
socketCheckTimer.schedule(new TimerTask() {
@Override
public void run() {
// System.out.println("[Chatcontroller, info: ] periodical socketcheck");
Thread.currentThread().setName("SocketcheckTimer");
if (!socket.isConnected() || socket.isClosed()) {
try {
messageRXBus.clear();
messageTXBus.clear();
socket.close();
chatController.setConnectedAndLoggedIn(false);
chatController.clearActiveChatMembers();
System.out.println("[Chatcontroller, Warning: ] Socket closed or disconnected");
ChatMessage killThreadPoisonPillMsg = new ChatMessage();
killThreadPoisonPillMsg.setMessageText(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
killThreadPoisonPillMsg.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
ChatMessage killThreadPoisonPillMsg2 = new ChatMessage();
killThreadPoisonPillMsg2.setMessageText(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
killThreadPoisonPillMsg2.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
messageRXBus.add(killThreadPoisonPillMsg);
messageTXBus.add(killThreadPoisonPillMsg2);
chatController.getReadThread().interrupt();
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
/**
* The next block will attempt to reinit the chatclient after accidental
* disconnection
*/
try {
if (!disconnectionPerformedByUser) {
messageRXBus.clear();
messageTXBus.clear();
socket = new Socket(hostname, port);
// readThread.interrupt();
chatController.setReadThread(new ReadThread(socket, chatController));
chatController.readThread.start();
chatController.setWriteThread(new WriteThread(socket, chatController));
chatController.writeThread.start();
messageProcessor = new MessageBusManagementThread(chatController, chatController);
messageProcessor.start();
System.out.println("[Chatcontroller, info: initialized new socket, is connected? ] "
+ socket.isConnected() + " " + socket.isClosed());
initialize23001();
Timer waitABit = new Timer();
socketCheckTimer.schedule(new TimerTask() {
@Override
public void run() {
Thread.currentThread().setName("waiting");
if (socket.isConnected()) {
chatController.setConnectedAndLoggedIn(true);
}
//just take a breath
}
}, 5000);
// keepAliveTimer.notify();
}
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}, 10000, 10000);
} catch (UnknownHostException ex) {
System.out.println("Server not found: " + ex.getMessage());
} catch (IOException ex) {
System.out.println("I/O Error: " + ex.getMessage());
}
while (readThread == null) {
// do nothing, wait!
System.out.println("Reader not ready.");
}
// initialize();//kst4contest.test 4 23001
initialize23001(); // init Chatcontroller for using port 23001
}
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.
*
* @return reachability service
*/
public ReachabilityService getReachabilityService() {
return reachabilityService;
}
/**
* Returns the runtime gross-field cache used by the new-locator filter.
*
* @return worked gross-field cache
*/
public WorkedGrossFieldCache getWorkedGrossFieldCache() {
return workedGrossFieldCache;
}
/**
* Rebuilds the gross-field cache from persistent SQLite data and enriches it with
* legacy ChatMember worked/qra rows where possible.
*/
public void rebuildWorkedGrossFieldCacheFromDatabase() {
if (dbHandler == null) {
return;
}
try {
workedGrossFieldCache.rebuildFromDatabaseSnapshot(dbHandler.fetchWorkedGrossFieldsFromDB());
workedGrossFieldCache.addWorkedBandsFromStoredChatMembers(dbHandler.fetchChatMemberWkdDataFromDB().values());
} catch (Exception exception) {
System.out.println("[ChatController, warning]: could not rebuild worked gross-field cache: "
+ exception.getMessage());
}
}
/**
* Persists and caches one worked gross field after a logger QSO packet.
*
* @param band worked band
* @param locator6 six-character Maidenhead locator
* @param workedCall worked station
* @param source logger/source name
*/
public void registerWorkedGrossField(Band band, String locator6, ChatMember workedCall, String source) {
if (band == null || locator6 == null) {
return;
}
String normalizedLocator6 = WorkedGrossFieldCache.extractLocator6(locator6);
if (normalizedLocator6 == null) {
return;
}
String callSignRaw = workedCall == null ? null : workedCall.getCallSignRaw();
try {
dbHandler.upsertWorkedGrossField(band, normalizedLocator6, callSignRaw, source);
} catch (Exception exception) {
System.out.println("[ChatController, warning]: could not persist worked gross field: "
+ exception.getMessage());
}
workedGrossFieldCache.addWorked(band, normalizedLocator6);
fireUserListUpdate("Worked gross field updated");
}
/**
* Returns true when the member's four-character grid square has already been
* worked on any band.
*
* <p>This method intentionally ignores the currently enabled own bands. The grid
* status in the user list is meant to be a simple and robust any-band indicator.</p>
*
* @param member member to inspect
* @return true if the gross grid square was worked on any band
*/
public boolean isGridSquareWorkedAny(ChatMember member) {
if (member == null || member.getQra() == null) {
return false;
}
return workedGrossFieldCache.isGrossFieldWorkedAny(member.getQra());
}
/**
* Returns true when the member's four-character grid square has not been worked
* on any band yet.
*
* <p>This is the predicate behind the optional "Only new grids" filter. It no
* longer depends on active station band settings.</p>
*
* @param member member to inspect
* @return true if the gross grid square is still new
*/
public boolean isNewGridSquare(ChatMember member) {
if (member == null || member.getQra() == null) {
return false;
}
return !workedGrossFieldCache.isGrossFieldWorkedAny(member.getQra());
}
public long getCurrentEpochTime() {
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
// System.out.println(currentTimeInUtc.format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm X")));
long millisecondsSinceEpoch = currentTimeInUtc.toInstant().toEpochMilli() / 1000;
// System.out.println(millisecondsSinceEpoch);
return millisecondsSinceEpoch;
}
/**
* Setting the initial parameters at the chat via port 23001 <br/>
* <br/>
* <b>Login parameter format is like that: </b><br/>
* LOGINC|do5amf|password|2|kst4contest1251|25|0|1|1663879818|0| <br/>
* SDONE|2| <br/>
*
* @throws InterruptedException
* @throws IOException
*/
public void initialize23001() throws InterruptedException, IOException {
if (on4KstConnectionManager != null) {
on4KstConnectionManager.start();
return;
}
messageTXBus.clear();
ChatMessage message = new ChatMessage();
new Timer().schedule(new TimerTask() {
@Override
public void run() {
Thread.currentThread().setName("LoginStringTimer");
//this is the original loginC without history abonnement
// String loginString = "";
// loginString = "LOGINC|" + chatPreferences.getStn_loginCallSign() + "|" + chatPreferences.getStn_loginPassword()
// + "|" + chatPreferences.getLoginChatCategoryMain().getCategoryNumber() + "|praktiKST v" + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER
// + "|25|0|1|" + getCurrentEpochTime() + "|0|";
String loginString = "";
loginString = "LOGINC|" + chatPreferences.getStn_loginCallSign() + "|" + chatPreferences.getStn_loginPassword()
+ "|" + chatPreferences.getLoginChatCategoryMain().getCategoryNumber() + "|praktiKST v" + ApplicationConstants.APPLICATION_CURRENT_VERSION
+ "|25|0|1|" + "0" + "|0|";
// System.out.println(loginString);
ChatMessage message = new ChatMessage();
message.setMessageText(loginString);
message.setMessageDirectedToServer(true);
getMessageTXBus().add(message);
}
}, 2000);
/**
* Entering second chat
*
* ACHAT|chat id|past messages number|past dx/map number|users list/update flags|last Unix timestamp for messages|last Unix timestamp for dx/map|
*/
if (this.chatController.getChatPreferences().isLoginToSecondChatEnabled()) { //only login to second if wished
new Timer().schedule(new TimerTask() {
@Override
public void run() { //test second chat
Thread.currentThread().setName("LoginStringTimerSecond");
String loginString = "";
loginString = "ACHAT|" + chatController.getChatPreferences().getLoginChatCategorySecond().getCategoryNumber() + "|" + "25"
+ "|" + "10" + "|2|" + getCurrentEpochTime() + "|" + getCurrentEpochTime();
// System.out.println(loginString);
ChatMessage message = new ChatMessage();
message.setMessageText(loginString);
message.setMessageDirectedToServer(true);
getMessageTXBus().add(message);
}
}, 5000);
}
/**
* end testing second chat
*
*/
new Timer().schedule(new TimerTask() {
@Override
public void run() {
Thread.currentThread().setName("SDONEStringTimer");
ChatMessage message = new ChatMessage();
message.setMessageText("SDONE|" + chatPreferences.getLoginChatCategoryMain().getCategoryNumber() + "|\r");
message.setMessageDirectedToServer(true);
getMessageTXBus().add(message);
}
}, 3000);
new Timer().schedule(new TimerTask() {
@Override
public void run() {
Thread.currentThread().setName("SETLOCTIMER");
ChatMessage message = new ChatMessage();
message.setMessageText("MSG|" + chatPreferences.getLoginChatCategoryMain().getCategoryNumber()
+ "|0|/SETLOC " + chatPreferences.getStn_loginLocatorMainCat() + "|0|\r");
message.setMessageDirectedToServer(true);
getMessageTXBus().add(message);
}
}, 4000);
new Timer().schedule(new TimerTask() {
@Override
public void run() {
Thread.currentThread().setName("SETNAMETIMER");
ChatMessage message = new ChatMessage();
message.setMessageText("MSG|" + chatPreferences.getLoginChatCategoryMain().getCategoryNumber()
+ "|0|/SETNAME " + chatPreferences.getStn_loginNameMainCat() + "|0|\r");
message.setMessageDirectedToServer(true);
getMessageTXBus().add(message);
}
}, 5000);
new Timer().schedule(new TimerTask() {
@Override
public void run() {
Thread.currentThread().setName("SETHereTimerMain");
ChatMessage message = new ChatMessage();
message.setMessageText("MSG|" + chatPreferences.getLoginChatCategoryMain().getCategoryNumber()
+ "|0|/BACK" + "|0|\r");
message.setMessageDirectedToServer(true);
getMessageTXBus().add(message);
}
}, 6500);
if (chatPreferences.isLoginToSecondChatEnabled()) { //only if second category had been enabled
new Timer().schedule(new TimerTask() {
@Override
public void run() {
Thread.currentThread().setName("SETNAMETIMER2nd");
ChatMessage message = new ChatMessage();
message.setMessageText("MSG|" + chatPreferences.getLoginChatCategorySecond().getCategoryNumber()
+ "|0|/SETNAME " + chatPreferences.getStn_loginNameSecondCat() + "|0|\r");
message.setMessageDirectedToServer(true);
getMessageTXBus().add(message);
}
}, 5500);
new Timer().schedule(new TimerTask() {
@Override
public void run() {
Thread.currentThread().setName("SETHereTimerSecond");
ChatMessage message = new ChatMessage();
message.setMessageText("MSG|" + chatPreferences.getLoginChatCategorySecond().getCategoryNumber()
+ "|0|/BACK" + "|0|\r");
message.setMessageDirectedToServer(true);
getMessageTXBus().add(message);
}
}, 7000);
}
// new Timer().schedule(new TimerTask() {
// HashMap<String, ChatMember> getWorkedDataFromDb;
//
// @Override
// public void run() {
//
// Thread.currentThread().setName("fetchWorkedFromDBTimer");
//
// try {
// getWorkedDataFromDb = dbHandler.fetchChatMemberWkdDataFromDB();
// } catch (SQLException e) {
// System.out.println("[Chatctrl, Error: ] got no worked data from DB due to communication error");
// }
//
// for (Iterator iterator = getLst_chatMemberList().iterator(); iterator.hasNext();) {
//
//
// ChatMember chatMember = (ChatMember) iterator.next();
// System.out.println("[Chatctrl]: Marking ChatMembers wkd information: "
// + getWorkedDataFromDb.get(chatMember.getCallSign()).getCallSign());
// chatMember.setWorked(getWorkedDataFromDb.get(chatMember.getCallSign()).isWorked());
// chatMember.setWorked144(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked144());
// ;
// chatMember.setWorked432(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked432());
// ;
// chatMember.setWorked1240(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked1240());
// ;
// chatMember.setWorked2300(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked2300());
// ;
// chatMember.setWorked3400(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked3400());
// ;
// chatMember.setWorked5600(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked5600());
// ;
// chatMember.setWorked10G(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked10G());
// /**
// * v1.2 since here
// * TODO: Change that, this ins not generative
// */
//
// chatMember.setQrv144(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv144());
// ;
// chatMember.setQrv432(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv432());
// ;
// chatMember.setQrv1240(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv1240());
// ;
// chatMember.setQrv2300(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv2300());
// ;
// chatMember.setQrv3400(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv3400());
// ;
// chatMember.setQrv5600(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv5600());
// ;
// chatMember.setQrv10G(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv10G());
// ;
// }
//
// /**
// *
// * This creates the list of the worked stations which had to be displayed in the
// * settings menu. TODO: May make this List editable
// *
// */
//
// getWorkedDataFromDb.forEach((key, value) -> {
//
// chatController.getLst_DBBasedWkdCallSignList().add(value);
//
//// System.out.println("Key=" + key + ", Value=" + value);
// });
// }
// }, 10000);
// message = new ChatMessage();
// message.setMessageText("MSG|2|0|/SETNAME " + ownChatMemberObject.getName() + "|0|\r");
// message.setMessageDirectedToServer(true);
// this.getMessageTXBus().add(message);
// message.setMessageText(ownCall.getCallSign());
// this.getMessageTXBus().add(message);
}
/**
* Reloads the worked/not-QRV state from the internal database and applies the
* result both to the active chatmember list and to the database table list in the
* settings dialog. UI-bound list modifications are executed on the JavaFX thread..
*/
public void refreshWorkedStateAndDatabaseListFromDatabase() {
HashMap<String, ChatMember> workedDataFromDatabase;
try {
workedDataFromDatabase = dbHandler.fetchChatMemberWkdDataFromDB();
} catch (SQLException e) {
System.out.println("[Chatctrl, Error: ] got no worked data from DB due to communication error");
e.printStackTrace();
return;
}
rebuildWorkedGrossFieldCacheFromDatabase();
HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase;
Platform.runLater(() -> {
applyWorkedAndQrvStateFromDatabase(
activeChatMembersByCallAndCategory.values(),
finalWorkedDataFromDatabase);
getLst_DBBasedWkdCallSignList().setAll(finalWorkedDataFromDatabase.values());
fireUserListUpdate("Worked database state refreshed");
});
}
/**
* Applies the worked and not-QRV state from a database snapshot to chat members.
* Database rows are keyed by normalized base callsign, so all active category
* and suffix variants receive the same persisted state.
*
* @param chatMembers members that should receive persisted state
* @param workedDataFromDatabase map keyed by normalized raw callsign
*/
/* package */ static void applyWorkedAndQrvStateFromDatabase(
Collection<ChatMember> chatMembers,
Map<String, ChatMember> workedDataFromDatabase
) {
if (chatMembers == null || workedDataFromDatabase == null) {
return;
}
for (ChatMember activeChatMember : chatMembers) {
if (activeChatMember == null) {
continue;
}
ChatMember storedChatMemberState = workedDataFromDatabase.get(activeChatMember.getCallSignRaw());
if (storedChatMemberState == null) {
continue;
}
activeChatMember.setWorked(storedChatMemberState.isWorked());
activeChatMember.setWorked144(storedChatMemberState.isWorked144());
activeChatMember.setWorked432(storedChatMemberState.isWorked432());
activeChatMember.setWorked1240(storedChatMemberState.isWorked1240());
activeChatMember.setWorked2300(storedChatMemberState.isWorked2300());
activeChatMember.setWorked3400(storedChatMemberState.isWorked3400());
activeChatMember.setWorked5600(storedChatMemberState.isWorked5600());
activeChatMember.setWorked10G(storedChatMemberState.isWorked10G());
activeChatMember.setWorked50(storedChatMemberState.isWorked50());
activeChatMember.setWorked70(storedChatMemberState.isWorked70());
activeChatMember.setQrv144(storedChatMemberState.isQrv144());
activeChatMember.setQrv432(storedChatMemberState.isQrv432());
activeChatMember.setQrv1240(storedChatMemberState.isQrv1240());
activeChatMember.setQrv2300(storedChatMemberState.isQrv2300());
activeChatMember.setQrv3400(storedChatMemberState.isQrv3400());
activeChatMember.setQrv5600(storedChatMemberState.isQrv5600());
activeChatMember.setQrv10G(storedChatMemberState.isQrv10G());
activeChatMember.setQrv50(storedChatMemberState.isQrv50());
activeChatMember.setQrv70(storedChatMemberState.isQrv70());
}
}
/**
* Resets all worked flags in the live GUI chatmember list.
*/
public void resetWorkedInfoInGuiLists() {
this.chatController.getLst_chatMemberList().forEach(
chatMember -> chatMember.resetWorkedInformationAtAllBands());
}
/**
* Resets all not-QRV flags in the live GUI chatmember list.
*/
public void resetQRVInfoInGuiLists() {
this.chatController.getLst_chatMemberList().forEach(
chatMember -> chatMember.resetQRVInformationAtAllBands());
}
/**
* Resets both worked and not-QRV flags in the live GUI chatmember list.
*/
public void resetWorkedAndQrvInfoInGuiLists() {
resetWorkedInfoInGuiLists();
resetQRVInfoInGuiLists();
}
// public void resetWorkedInfoInGuiLists() {
//
// this.chatController.getLst_chatMemberList().forEach(
// chatMember -> chatMember.resetWorkedInformationAtAllBands());
//
// }
//
// public void resetQRVInfoInGuiLists() {
//
// this.chatController.getLst_chatMemberList().forEach(
// chatMember -> chatMember.resetQRVInformationAtAllBands());
//
// }
/**
* Setting the initial parameters at the chat
*
* @throws InterruptedException
* @throws IOException
*/
public void initialize23000() throws InterruptedException, IOException {
ChatMessage message = new ChatMessage();
// message.setDirectedToServer(true);
message.setMessageText(ownChatMemberObject.getCallSign());
this.getMessageTXBus().add(message);
message = new ChatMessage();
// message.setDirectedToServer(true);
message.setMessageText(password);
this.getMessageTXBus().add(message);
//
message = new ChatMessage();
// message.setDirectedToServer(true);
message.setMessageText(chatCategoryMain + "");
this.getMessageTXBus().add(message);
//
message = new ChatMessage();
// message.setDirectedToServer(true);
message.setMessageText("/set qra " + ownChatMemberObject.getQra());
this.getMessageTXBus().add(message);
//
message = new ChatMessage();
// message.setDirectedToServer(true);
message.setMessageText("/set name " + ownChatMemberObject.getName());
this.getMessageTXBus().add(message);
//
message = new ChatMessage();
// message.setDirectedToServer(true);
message.setMessageText("/set here");
this.getMessageTXBus().add(message);
// message = new ChatMessage();
// message.setDirectedToServer(true);
// message.setMessageText("/show user");
// this.getMessageTXBus().add(message);
// will done by another Thread
}
public static final class UiReminderEvent {
private final String callSignRaw;
private final int minutesBefore;
private final long epochMs;
public UiReminderEvent(String callSignRaw, int minutesBefore, long epochMs) {
this.callSignRaw = callSignRaw;
this.minutesBefore = minutesBefore;
this.epochMs = epochMs;
}
public String getCallSignRaw() { return callSignRaw; }
public int getMinutesBefore() { return minutesBefore; }
public long getEpochMs() { return epochMs; }
}
public void fireUiReminderEvent(String callSignRaw, int minutesBefore) {
final String raw = callSignRaw == null ? null : callSignRaw.trim().toUpperCase();
final long now = System.currentTimeMillis();
// Ensure property updates happen on FX thread
if (Platform.isFxApplicationThread()) {
lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now));
} else {
Platform.runLater(() -> lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now)));
}
}
private final ObjectProperty<UiReminderEvent> lastUiReminderEvent = new SimpleObjectProperty<>(null);
public ReadOnlyObjectProperty<UiReminderEvent> lastUiReminderEventProperty() {
return lastUiReminderEvent;
}
/**
* Helper method to check if a chatmember is in my beam(range)
*
* @param member
* @return
*/
public boolean isChatMemberInMyBeam(ChatMember member) {
if (member == null || member.getQTFdirection() == null) return false;
double targetAz = member.getQTFdirection();
double myAz = getChatPreferences().getActualQTF().get();
double beamWidth = getChatPreferences().getStn_antennaBeamWidthDeg();
return DirectionUtils.isAngleInRange(targetAz, myAz, beamWidth);
}
/**
* Checks whether a message belongs to a monitored base callsign.
*
* <p>QSO monitoring intentionally combines all visible ON4KST variants of
* the same station. An entry for DN9APW therefore also matches DN9APW-2,
* DN9APW-70 and other suffixes. This affects monitoring only; message
* routing continues to use the complete callsign and chat category.</p>
*
* @param chatMessage message to examine
* @return true if sender or receiver belongs to a monitored base callsign
*/
public boolean isSniffedMessage(ChatMessage chatMessage) {
if (chatMessage == null
|| chatMessage.getSender() == null
|| chatMessage.getReceiver() == null) {
return false;
}
String senderCall =
chatMessage.getSender().getCallSign();
String receiverCall =
chatMessage.getReceiver().getCallSign();
if (senderCall == null || receiverCall == null) {
return false;
}
if (lstNotify_QSOSniffer_sniffedCallSignList == null
|| lstNotify_QSOSniffer_sniffedCallSignList.isEmpty()) {
return false;
}
String senderBaseCall =
ChatMember.normalizeCallSignToBaseCallSign(
senderCall
);
String receiverBaseCall =
ChatMember.normalizeCallSignToBaseCallSign(
receiverCall
);
boolean observedCall =
lstNotify_QSOSniffer_sniffedCallSignList
.stream()
.anyMatch(
monitoredCall -> {
String monitoredBaseCall =
ChatMember
.normalizeCallSignToBaseCallSign(
monitoredCall
);
return monitoredBaseCall != null
&& (
monitoredBaseCall
.equalsIgnoreCase(
senderBaseCall
)
|| monitoredBaseCall
.equalsIgnoreCase(
receiverBaseCall
)
);
}
);
if (!observedCall) {
return false;
}
String myCall =
getChatPreferences() != null
? getChatPreferences().getStn_loginCallSign()
: null;
String myRawCall =
getChatPreferences() != null
? getChatPreferences().getStn_loginCallSignRaw()
: null;
/*
* Messages which are already addressed directly to the local station
* belong in the PM table without an additional Sniffed marker.
*/
boolean directedToOwnCall =
(myCall != null
&& receiverCall.equalsIgnoreCase(myCall))
|| (myRawCall != null
&& receiverCall.equalsIgnoreCase(myRawCall));
return !directedToOwnCall;
}
/**
* changes the chatmessage if it had been a sniffed one and not directed to me. Only for marking.
* @param chatMessage
* @return
*/
public String formatChatMessageTextForDisplay(ChatMessage chatMessage) {
if (chatMessage == null) {
return "";
}
String msgText = chatMessage.getMessageText();
if (msgText == null) {
msgText = "";
}
if (!isSniffedMessage(chatMessage)) {
return msgText;
}
String senderCall = chatMessage.getSender() != null ? chatMessage.getSender().getCallSign() : "";
String receiverCall = chatMessage.getReceiver() != null ? chatMessage.getReceiver().getCallSign() : "";
return "Sniffed: (" + senderCall + " > " + receiverCall + ") " + msgText;
}
}