fix: address Compose UI regressions

This commit is contained in:
Marc Froehlich committed 2026-10-24 00:06:00 +02:00
1 parent 936efdd8dd
commit 971fb35109
66 files changed
+4436 -766

No files matched your search

@@ -2901,7 +2901,7 @@ private SimpleRoster<String>
chatPreferences = new ChatPreferences(preferencesRelativeFileName);
chatPreferences.readPreferencesFromXmlFile();
Path historyFile = Path.of(ApplicationFileUtils.getFilePath(
Path historyFile = Path.of(ApplicationFileUtils.getDebugFilePath(
ApplicationConstants.APPLICATION_NAME,
MessageHistoryRecorder.CURRENT_FILE_NAME));
messageHistoryRecorder = new MessageHistoryRecorder(
@@ -758,8 +758,15 @@ public class MessageBusManagementThread extends Thread {
// Built only now: the receiver field has always been read after the sender.
InboundChatFrame frame = InboundChatFrame.of(fields);
boolean publicMessage = frame.receiverField().equals("0");
newMessageArrived.setMessageDirectedToCommunity(publicMessage);
newMessageArrived.setMessageDirectedToMe(
publicMessage
&& isPublicMessageExplicitlyAddressedTo(
frame.text(),
prefs.getStn_loginCallSign()));
if (frame.receiverField().equals("0")) {
if (publicMessage) {
// message is not directed to anyone, move it to the cq messages.
newMessageArrived.setReceiver(createAllReceiver());
@@ -778,6 +785,7 @@ public class MessageBusManagementThread extends Thread {
newMessageArrived.getSender().getCallSign(),
newMessageArrived.getReceiver().getCallSign(),
prefs.getStn_loginCallSign());
newMessageArrived.setMessageDirectedToMe(route == ChatRoute.TO_ME);
DirectionalOpportunity opportunity = null;
boolean senderAlreadyInAngleAndRange = false;
@@ -1714,6 +1722,33 @@ public class MessageBusManagementThread extends Thread {
return ChatRoute.BETWEEN_OTHERS;
}
/**
* Recognizes the structured address prefix produced by ON4KST for a public
* {@code /cq CALL ...} message.
*
* <p>The address must be the first non-whitespace token and occupy the complete
* parenthesized prefix. A callsign occurring later in ordinary prose is therefore
* not treated as an address.</p>
*/
static boolean isPublicMessageExplicitlyAddressedTo(String messageText, String ownCall) {
if (messageText == null || ownCall == null || ownCall.isBlank()) {
return false;
}
String text = messageText.stripLeading();
if (!text.startsWith("(")) {
return false;
}
int closingParenthesis = text.indexOf(')');
if (closingParenthesis <= 1) {
return false;
}
String addressedCall = text.substring(1, closingParenthesis).trim();
return addressedCall.equalsIgnoreCase(ownCall.trim());
}
/**
* Returns whether the short tick sound for a new directional opportunity is
* played: only with simple sounds enabled and only on the transition into
@@ -425,6 +425,7 @@ public class ChatPreferences {
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
private boolean GUIstationMapPathAnalysisVisible = true;
private boolean GUIstationMapClusteringEnabled = true;
private boolean GUIstationMapAlwaysOnTop = false;
private final Map<String, Double> tableColumnWidths = new LinkedHashMap<>();
private static final String TAG_TABLE_COLUMN_WIDTH = "tableColumnWidth";
@@ -803,6 +804,14 @@ public class ChatPreferences {
this.GUIstationMapClusteringEnabled = GUIstationMapClusteringEnabled;
}
public boolean isGUIstationMapAlwaysOnTop() {
return GUIstationMapAlwaysOnTop;
}
public void setGUIstationMapAlwaysOnTop(boolean GUIstationMapAlwaysOnTop) {
this.GUIstationMapAlwaysOnTop = GUIstationMapAlwaysOnTop;
}
/**
* Returns a stored width for one stable table/leaf-column identity.
*
@@ -2395,6 +2404,12 @@ public class ChatPreferences {
);
guiOptions.appendChild(GUIstationMapClusteringEnabled);
Element GUIstationMapAlwaysOnTop = doc.createElement("GUIstationMapAlwaysOnTop");
GUIstationMapAlwaysOnTop.setTextContent(
String.valueOf(this.isGUIstationMapAlwaysOnTop())
);
guiOptions.appendChild(GUIstationMapAlwaysOnTop);
appendTableColumnWidths(doc, guiOptions);
appendWindowPositions(doc, guiOptions);
@@ -2484,6 +2499,8 @@ public class ChatPreferences {
getGUIstationMapStagePositionXY()[0] + ";" + getGUIstationMapStagePositionXY()[1]);
upsertDirectChildText(document, guiOptions, "GUIstationMapClusteringEnabled",
String.valueOf(isGUIstationMapClusteringEnabled()));
upsertDirectChildText(document, guiOptions, "GUIstationMapAlwaysOnTop",
String.valueOf(isGUIstationMapAlwaysOnTop()));
}
private void appendTableColumnWidths(Document document, Element guiOptions) {
@@ -3223,6 +3240,7 @@ public class ChatPreferences {
*
***********************************************/
this.setGUIstationMapClusteringEnabled(true);
this.setGUIstationMapAlwaysOnTop(false);
list = doc.getElementsByTagName("guiOptions");
if (list.getLength() != 0) {
@@ -3272,6 +3290,12 @@ public class ChatPreferences {
"GUIstationMapClusteringEnabled"
));
this.setGUIstationMapAlwaysOnTop(getBooleanOrDefault(
element,
false,
"GUIstationMapAlwaysOnTop"
));
// Splitpane divider positions
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
if (s1 != null) {
@@ -1,15 +1,19 @@
package kst4contest.utils;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.FileHandler;
import java.util.logging.ErrorManager;
import java.util.logging.Formatter;
import java.util.logging.Handler;
import java.util.logging.Level;
@@ -20,6 +24,10 @@ import java.util.logging.Logger;
public final class ApplicationFileLogging implements AutoCloseable {
/** Logger namespace owned by the application. */
private static final String APP_LOGGER_NAME = "kst4contest";
/** Maximum size of the active error log before it is archived. */
private static final long DEFAULT_SIZE_LIMIT_BYTES = 20L * 1024L * 1024L;
/** Active error log plus four archived generations. */
private static final int DEFAULT_FILE_COUNT = 5;
/** Runtime switch for additional application diagnostics. */
private final AtomicBoolean debugEnabled = new AtomicBoolean();
@@ -44,6 +52,28 @@ public final class ApplicationFileLogging implements AutoCloseable {
final boolean debugEnabled
)
throws IOException {
this(logFile, debugEnabled,
DEFAULT_SIZE_LIMIT_BYTES, DEFAULT_FILE_COUNT);
}
/** Test seam for exercising rotation without writing multi-megabyte files. */
ApplicationFileLogging(
final Path logFile,
final boolean debugEnabled,
final long sizeLimitBytes,
final int fileCount
) throws IOException {
this(logFile, debugEnabled, sizeLimitBytes, fileCount, ignored -> { });
}
/** Test seam for a transient file-system error after the active stream closes. */
ApplicationFileLogging(
final Path logFile,
final boolean debugEnabled,
final long sizeLimitBytes,
final int fileCount,
final RotationFaultInjector rotationFaultInjector
) throws IOException {
final Path parent = logFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
@@ -51,15 +81,175 @@ public final class ApplicationFileLogging implements AutoCloseable {
this.debugEnabled.set(debugEnabled);
previousAppLevel = appLogger.getLevel();
appLogger.setLevel(Level.FINE);
final FileHandler fileHandler =
new FileHandler(logFile.toString(), true);
final Handler fileHandler = new BoundedRotatingFileHandler(
logFile, sizeLimitBytes, fileCount, rotationFaultInjector);
fileHandler.setLevel(Level.ALL);
fileHandler.setFilter(this::isLoggable);
fileHandler.setFormatter(new UtcLogFormatter());
try {
appLogger.setLevel(Level.FINE);
rootLogger.addHandler(fileHandler);
} catch (RuntimeException exception) {
fileHandler.close();
try {
appLogger.setLevel(previousAppLevel);
} catch (RuntimeException restoreFailure) {
exception.addSuppressed(restoreFailure);
}
throw exception;
}
handler = fileHandler;
rootLogger.addHandler(handler);
}
/** Injects a test-only failure at the point where archive operations begin. */
@FunctionalInterface
interface RotationFaultInjector {
void beforeArchive(Path logFile) throws IOException;
}
/** Keeps the active canonical file name while retaining bounded generations. */
private static final class BoundedRotatingFileHandler extends Handler {
private final Path logFile;
private final long sizeLimitBytes;
private final int fileCount;
private final RotationFaultInjector rotationFaultInjector;
private OutputStream output;
private long currentSize;
private BoundedRotatingFileHandler(
final Path logFile,
final long sizeLimitBytes,
final int fileCount,
final RotationFaultInjector rotationFaultInjector
) throws IOException {
if (sizeLimitBytes <= 0L) {
throw new IllegalArgumentException("sizeLimitBytes must be positive");
}
if (fileCount <= 0) {
throw new IllegalArgumentException("fileCount must be positive");
}
this.logFile = logFile;
this.sizeLimitBytes = sizeLimitBytes;
this.fileCount = fileCount;
this.rotationFaultInjector = rotationFaultInjector;
open(true);
}
@Override
public synchronized void publish(final LogRecord record) {
if (!isLoggable(record)) {
return;
}
final byte[] encoded = getFormatter().format(record)
.getBytes(StandardCharsets.UTF_8);
try {
if (output == null) {
open(true);
}
if (currentSize > 0L
&& currentSize + encoded.length > sizeLimitBytes) {
try {
rotate();
} catch (IOException | RuntimeException exception) {
reportError("Could not rotate " + logFile,
exception, ErrorManager.WRITE_FAILURE);
if (output == null) {
return;
}
}
}
output.write(encoded);
output.flush();
currentSize += encoded.length;
} catch (IOException | RuntimeException exception) {
reportError("Could not write or rotate " + logFile,
exception, ErrorManager.WRITE_FAILURE);
}
}
@Override
public synchronized void flush() {
if (output == null) {
return;
}
try {
output.flush();
} catch (IOException exception) {
reportError("Could not flush " + logFile,
exception, ErrorManager.FLUSH_FAILURE);
}
}
@Override
public synchronized void close() {
closeOutput();
}
private void rotate() throws IOException {
closeOutput();
try {
rotationFaultInjector.beforeArchive(logFile);
if (fileCount > 1) {
Files.deleteIfExists(generation(fileCount - 1));
for (int generation = fileCount - 2;
generation >= 1;
generation--) {
Path source = generation(generation);
if (Files.exists(source)) {
Files.move(source, generation(generation + 1),
StandardCopyOption.REPLACE_EXISTING);
}
}
if (Files.exists(logFile)) {
Files.move(logFile, generation(1),
StandardCopyOption.REPLACE_EXISTING);
}
}
open(false);
} catch (IOException | RuntimeException exception) {
try {
open(true);
} catch (IOException | RuntimeException recoveryFailure) {
exception.addSuppressed(recoveryFailure);
}
throw exception;
}
}
private Path generation(final int generation) {
return logFile.resolveSibling(
logFile.getFileName() + "." + generation);
}
private void open(final boolean append) throws IOException {
output = append
? Files.newOutputStream(logFile,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.APPEND)
: Files.newOutputStream(logFile,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING);
currentSize = Files.size(logFile);
}
private void closeOutput() {
if (output == null) {
return;
}
try {
output.close();
} catch (IOException exception) {
reportError("Could not close " + logFile,
exception, ErrorManager.CLOSE_FAILURE);
} finally {
output = null;
}
}
}
/** Applies the runtime debug preference to subsequent file log records. */
@@ -29,6 +29,21 @@ public class ApplicationFileUtils {
fileName).toString();
}
/**
* Gets the path of a diagnostic file below the application's debug directory.
*
* @param applicationName application directory name
* @param fileName diagnostic file name
* @return full path below {@code .applicationName/debug}
*/
public static String getDebugFilePath(
final String applicationName,
final String fileName
) {
return Path.of(getFilePath(applicationName, "debug"), fileName)
.toString();
}
/**
* Copies a resource to the application folder if required.
* @param applicationName Name of the application.
@@ -146,6 +146,41 @@ class MessageBusManagementThreadChatFrameTest {
verifyNoInteractions(playAudioUtils);
}
@Test
@Timeout(10)
void genuinePrivateMessageCarriesTheAuthoritativeRouteFlags() throws InterruptedException {
runThrough(chatFrame("CH", REMOTE_CALL, "pse ur qrg", MY_CALL));
ChatMessage published = singlePublished();
assertEquals(MY_CALL, published.getReceiver().getCallSign());
assertTrue(published.isMessageDirectedToMe());
assertFalse(published.isMessageDirectedToCommunity());
assertEquals(ChatCategory.VUHF, published.getChatCategory().getCategoryNumber());
}
@Test
@Timeout(10)
void publicMessageWithLeadingAddressIsMarkedAsExplicitlyAddressedToMe()
throws InterruptedException {
runThrough(chatFrame("CH", REMOTE_CALL, "(" + MY_CALL + ") pse ur qrg", "0"));
ChatMessage published = singlePublished();
assertEquals("ALL", published.getReceiver().getCallSign());
assertTrue(published.isMessageDirectedToCommunity());
assertTrue(published.isMessageDirectedToMe());
}
@Test
@Timeout(10)
void arbitraryPublicMentionIsNotMarkedAsExplicitlyAddressedToMe()
throws InterruptedException {
runThrough(chatFrame("CH", REMOTE_CALL, "ask " + MY_CALL + " about the sked", "0"));
ChatMessage published = singlePublished();
assertTrue(published.isMessageDirectedToCommunity());
assertFalse(published.isMessageDirectedToMe());
}
// --- sent by me ---------------------------------------------------------
@Test
@@ -146,6 +146,25 @@ class MessageHistoryRecorderTest {
});
}
@Test
void dedicatedDebugDirectoryIsCreatedWithoutMovingLegacyHistory()
throws Exception {
Path legacyHistory = historyFile();
Files.writeString(legacyHistory, "legacy history\n");
Path debugDirectory = temporaryDirectory.resolve("debug");
try (MessageHistoryRecorder recorder = new MessageHistoryRecorder(
debugDirectory,
true,
Clock.fixed(FIXED_INSTANT, ZoneOffset.UTC))) {
recorder.recordRx("OK|");
}
assertEquals("legacy history\n", Files.readString(legacyHistory));
assertTrue(Files.isRegularFile(
debugDirectory.resolve("Messagehistory.raw")));
}
private MessageHistoryRecorder recorder(boolean enabled) {
return new MessageHistoryRecorder(
temporaryDirectory,
@@ -0,0 +1,82 @@
package kst4contest.test;
import kst4contest.model.ChatPreferences;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatPreferencesStationMapAlwaysOnTopTest {
@TempDir
Path temporaryDirectory;
@Test
void alwaysOnTopIsDisabledByDefault() {
assertFalse(new ChatPreferences(temporaryDirectory.resolve("unused.xml"))
.isGUIstationMapAlwaysOnTop());
}
@Test
void enabledAlwaysOnTopSurvivesFullRoundTrip() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
ChatPreferences written = new ChatPreferences(preferencesFile);
written.setGUIstationMapAlwaysOnTop(true);
assertTrue(written.writePreferencesToXmlFile());
assertTrue(Files.readString(preferencesFile).contains(
"<GUIstationMapAlwaysOnTop>true</GUIstationMapAlwaysOnTop>"));
ChatPreferences restored = new ChatPreferences(preferencesFile);
assertTrue(restored.readPreferencesFromXmlFile());
assertTrue(restored.isGUIstationMapAlwaysOnTop());
}
@Test
void missingOrMalformedValueKeepsAlwaysOnTopDisabled() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("legacy.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>8</configVersion>
<guiOptions>
<GUIstationMapAlwaysOnTop>sometimes</GUIstationMapAlwaysOnTop>
</guiOptions>
</praktiKST>
""");
ChatPreferences restored = new ChatPreferences(preferencesFile);
restored.setGUIstationMapAlwaysOnTop(true);
assertTrue(restored.readPreferencesFromXmlFile());
assertFalse(restored.isGUIstationMapAlwaysOnTop());
}
@Test
void selectiveLayoutWritePersistsAlwaysOnTopWithoutFunctionalSettings() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("selective.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>8</configVersion>
<station><LoginCallSign>SAVED-CALL</LoginCallSign></station>
<guiOptions/>
</praktiKST>
""");
ChatPreferences preferences = new ChatPreferences(preferencesFile);
assertTrue(preferences.readPreferencesFromXmlFile());
preferences.setStn_loginCallSign("UNSAVED-CALL");
preferences.setGUIstationMapAlwaysOnTop(true);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
String xml = Files.readString(preferencesFile);
assertTrue(xml.contains("<LoginCallSign>SAVED-CALL</LoginCallSign>"));
assertFalse(xml.contains("UNSAVED-CALL"));
assertTrue(xml.contains("<GUIstationMapAlwaysOnTop>true</GUIstationMapAlwaysOnTop>"));
}
}
@@ -1,11 +1,15 @@
package kst4contest.utils;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -47,4 +51,87 @@ class ApplicationFileLoggingTest {
assertTrue(log.contains("error while enabled"));
assertTrue(log.contains("Z\tWARNING\t"));
}
@Test
void createsTheDebugDirectoryOnDemand() throws Exception {
Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log");
Logger logger = Logger.getLogger(
"kst4contest.test.filelogging." + UUID.randomUUID());
try (ApplicationFileLogging logging =
new ApplicationFileLogging(logFile, false)) {
logger.warning("directory test");
}
assertTrue(Files.isRegularFile(logFile));
}
@Test
void rotatesAtTheConfiguredLimitAndKeepsFiveFiles() throws Exception {
Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log");
Logger logger = Logger.getLogger(
"kst4contest.test.filelogging." + UUID.randomUUID());
try (ApplicationFileLogging logging =
new ApplicationFileLogging(logFile, true, 320L, 5)) {
for (int index = 0; index < 40; index++) {
logger.info("rotation entry " + index + " " + "x".repeat(120));
}
}
assertTrue(Files.isRegularFile(logFile));
for (int generation = 1; generation < 5; generation++) {
assertTrue(Files.isRegularFile(
logFile.resolveSibling(logFile.getFileName() + "." + generation)));
}
try (var files = Files.list(logFile.getParent())) {
assertEquals(5L, files.filter(path -> path.getFileName().toString()
.startsWith("kst4contest-errors.log")).count());
}
}
@Test
void anUnusableLogFileIsReportedWithoutChangingTheLoggerLevel() throws Exception {
Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log");
Files.createDirectories(logFile);
Logger appLogger = Logger.getLogger("kst4contest");
Level previousLevel = appLogger.getLevel();
appLogger.setLevel(Level.SEVERE);
try {
assertThrows(IOException.class, () -> new ApplicationFileLogging(
logFile, false));
assertEquals(Level.SEVERE, appLogger.getLevel());
} finally {
appLogger.setLevel(previousLevel);
}
}
@Test
void continuesWritingAfterATransientRotationFailure() throws Exception {
Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log");
Logger logger = Logger.getLogger(
"kst4contest.test.filelogging." + UUID.randomUUID());
AtomicBoolean failNextRotation = new AtomicBoolean(true);
try (ApplicationFileLogging logging = new ApplicationFileLogging(
logFile, true, 320L, 5,
ignored -> {
if (failNextRotation.getAndSet(false)) {
throw new IOException("simulated transient rotation failure");
}
})) {
logger.info("before rotation " + "x".repeat(180));
logger.info("written despite failed rotation " + "x".repeat(180));
logger.info("written after rotation recovery " + "x".repeat(180));
}
Path firstGeneration = logFile.resolveSibling(
logFile.getFileName() + ".1");
assertTrue(Files.readString(firstGeneration)
.contains("written despite failed rotation"));
assertTrue(Files.readString(logFile)
.contains("written after rotation recovery"));
}
}
@@ -0,0 +1,19 @@
package kst4contest.utils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class ApplicationFileUtilsTest {
@Test
void debugFilesResolveBelowTheDedicatedApplicationDirectory() {
assertEquals(
Path.of(ApplicationFileUtils.getFilePath("praktiKST", "debug"),
"kst4contest-errors.log").toString(),
ApplicationFileUtils.getDebugFilePath(
"praktiKST", "kst4contest-errors.log"));
}
}