diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 13ed3106..0444138d 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -135,6 +135,8 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen ### Terrain data providers - The active terrain profile provider is Open-Meteo using Copernicus GLO-90 data. +- The terrain profile cache lives in its own global database `~/.praktiKST/terrainprofilecache.db`. It is deliberately not part of an operator profile: terrain profiles are pure geometry derived from two locators and a sample count, and at a multi operator station both operators share one location, so a per-profile copy would only double the traffic against the terrain service. +- Cached entries are separated by owner identity through the primary key (`owner_callsign_raw` + `owner_locator6`). Earlier versions stored a single owner identity in a meta table and dropped the whole cache whenever the configured callsign or locator changed; with several operator profiles that would discard every computed profile on each switch. The old `TerrainProfileCache*` tables inside `praktiKST.db` are left in place and are still readable by older releases; the new file starts empty and refills itself. - `OfflineDemImportService` only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain. ### ON4KST session and authentication diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md index 531ae147..05162a99 100644 --- a/github_docs/de-Changelog.md +++ b/github_docs/de-Changelog.md @@ -26,6 +26,8 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un ### Geändert +- **Zwischenspeicher der Geländeprofile getrennt:** Berechnete Geländeprofile liegen jetzt in der eigenen, gemeinsam genutzten Datei `terrainprofilecache.db` und werden nach Besitzer getrennt gespeichert. Bisher wurde der gesamte Zwischenspeicher gelöscht, sobald sich Rufzeichen oder Locator änderten; bei einem Profilwechsel wäre damit jedes berechnete Profil verloren gegangen. + - **Rufzeichen-Vorgabe ist leer:** Fehlt in der `preferences.xml` ein Login-Rufzeichen, bleibt das Feld jetzt leer, statt auf ein im Programm hinterlegtes Rufzeichen zurückzufallen. Ein neu angelegtes Profil startet damit bewusst ohne Anmeldedaten. ### Behoben diff --git a/github_docs/de-Konfiguration.md b/github_docs/de-Konfiguration.md index 62a6e001..542fc2bf 100644 --- a/github_docs/de-Konfiguration.md +++ b/github_docs/de-Konfiguration.md @@ -891,6 +891,8 @@ Mehrere Operateure an einem Rechner brauchen unterschiedliche Rufzeichen, Locato Unter Windows entsprechend unterhalb von `%USERPROFILE%\.praktiKST\`. +Alle übrigen Daten bleiben gemeinsam: Klangdateien, Farbschemata, DEM- und Terrainpakete, der Zwischenspeicher der Geländeprofile (`terrainprofilecache.db`), das Fehlerprotokoll und die Versionsinformationen. + Das Profil **Default** benutzt weiterhin genau die Dateien, die eine bestehende Installation schon hat. **Bei der Aktualisierung auf v1.50 wird keine Datei verschoben, kopiert oder umgeschrieben.** Wer eine ältere KST4Contest-Version wieder installiert, findet seine Konfiguration und seine gearbeiteten Stationen unverändert vor. ### Gemeinsame oder eigene gearbeitete Stationen diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md index a15f4e08..ae841fcb 100644 --- a/github_docs/en-Changelog.md +++ b/github_docs/en-Changelog.md @@ -26,6 +26,8 @@ Several operators sharing one computer can now use their own callsigns, locators ### Changed +- **The terrain profile cache is separate:** computed terrain profiles now live in their own shared file `terrainprofilecache.db` and are stored per owner. Previously the whole cache was dropped whenever the callsign or locator changed, which would have discarded every computed profile on each profile switch. + - **The default login callsign is empty:** if `preferences.xml` has no login callsign, the field now stays empty instead of falling back to a callsign compiled into the program. A newly created profile therefore deliberately starts without credentials. ### Fixed diff --git a/github_docs/en-Configuration.md b/github_docs/en-Configuration.md index 85c0a55d..3155c98a 100644 --- a/github_docs/en-Configuration.md +++ b/github_docs/en-Configuration.md @@ -948,6 +948,8 @@ Several operators sharing one computer need different callsigns, locators and la On Windows the same files live below `%USERPROFILE%\.praktiKST\`. +Everything else stays shared: audio files, colour schemes, DEM and terrain packages, the terrain profile cache (`terrainprofilecache.db`), the error log and the version information. + The **Default** profile keeps using exactly the files an existing installation already has. **Upgrading to v1.50 moves, copies and rewrites nothing.** Anyone reinstalling an older KST4Contest release finds their configuration and their worked stations unchanged. ### Shared or own worked stations diff --git a/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java b/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java index c01b5e0e..94b29701 100644 --- a/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java +++ b/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java @@ -1,9 +1,11 @@ package kst4contest.view.map; import kst4contest.ApplicationConstants; -import kst4contest.controller.DBController; import kst4contest.utils.ApplicationFileUtils; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; @@ -15,30 +17,44 @@ import java.util.Locale; import java.util.Optional; /** - * Persistent terrain profile cache stored in the application's existing SQLite database. + * Persistent terrain profile cache, stored globally in its own SQLite database. * - * The cache is intentionally owner-bound: - * if the configured own callsign or own locator changes, all cached terrain - * profiles are cleared automatically. + *
Terrain profiles are pure geometry derived from two locators and a sample count. + * They do not belong to one operator, so the cache is deliberately not part of an + * operator profile: at a multi operator station both operators share one location, and + * duplicating the cache would double the traffic against an external terrain service.
+ * + *Entries are separated by owner identity through the primary key instead. Earlier + * versions kept a single owner identity and dropped the whole cache whenever the + * configured callsign or locator changed; with several operator profiles that would + * discard every computed profile on each switch.
*/ public final class TerrainProfileCacheRepository { - private static final String META_KEY_OWNER_CALLSIGN_RAW = "terrain_cache_owner_callsign_raw"; - private static final String META_KEY_OWNER_LOCATOR6 = "terrain_cache_owner_locator6"; + /** + * File name of the global terrain profile cache below the application directory. + */ + public static final String TERRAIN_CACHE_DATABASE_FILE = "terrainprofilecache.db"; private final String databasePath; public TerrainProfileCacheRepository() { - ApplicationFileUtils.copyResourceIfRequired( - ApplicationConstants.APPLICATION_NAME, - DBController.DATABASE_RESOURCE, - DBController.DATABASE_FILE - ); - this.databasePath = ApplicationFileUtils.getFilePath( ApplicationConstants.APPLICATION_NAME, - DBController.DATABASE_FILE + TERRAIN_CACHE_DATABASE_FILE ); + + // SQLite only creates the database file itself, not the directory holding it. + Path applicationDirectory = Path.of(databasePath).getParent(); + + if (applicationDirectory != null) { + try { + Files.createDirectories(applicationDirectory); + } catch (IOException exception) { + System.err.println("[StationMap] Terrain cache directory could not be created: " + + exception.getMessage()); + } + } } public synchronized OptionalEntries of other owners stay untouched. The cached profiles of an identity are + * separated by the primary key already, so a different callsign or locator simply + * misses the cache instead of invalidating everybody else's entries.
+ */ private void ensureOwnerIdentity(Connection connection, String currentOwnerCallsignRaw, String currentOwnerLocator6) throws Exception { - String normalizedOwnerCallsignRaw = normalize(currentOwnerCallsignRaw); - String normalizedOwnerLocator6 = normalize(currentOwnerLocator6); - - String storedOwnerCallsignRaw = readMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW); - String storedOwnerLocator6 = readMetaValue(connection, META_KEY_OWNER_LOCATOR6); - - boolean callsignChanged = storedOwnerCallsignRaw != null && !storedOwnerCallsignRaw.equals(normalizedOwnerCallsignRaw); - boolean locatorChanged = storedOwnerLocator6 != null && !storedOwnerLocator6.equals(normalizedOwnerLocator6); - - if (callsignChanged || locatorChanged) { - clearTerrainCache(connection); - } - - writeMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW, normalizedOwnerCallsignRaw); - writeMetaValue(connection, META_KEY_OWNER_LOCATOR6, normalizedOwnerLocator6); - } - - private void clearTerrainCache(Connection connection) throws Exception { - try (Statement statement = connection.createStatement()) { - statement.executeUpdate("DELETE FROM TerrainProfileCache"); - } - } - - private String readMetaValue(Connection connection, String key) throws Exception { - try (PreparedStatement statement = connection.prepareStatement( - "SELECT meta_value FROM TerrainProfileCacheMeta WHERE meta_key = ?")) { - statement.setString(1, key); - - try (ResultSet resultSet = statement.executeQuery()) { - return resultSet.next() ? resultSet.getString(1) : null; - } - } - } - - private void writeMetaValue(Connection connection, String key, String value) throws Exception { try (PreparedStatement statement = connection.prepareStatement(""" - INSERT INTO TerrainProfileCacheMeta (meta_key, meta_value) - VALUES (?, ?) - ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value + INSERT INTO TerrainProfileCacheOwner ( + owner_callsign_raw, owner_locator6, last_used_epoch_ms + ) VALUES (?, ?, ?) + ON CONFLICT(owner_callsign_raw, owner_locator6) DO UPDATE SET + last_used_epoch_ms = excluded.last_used_epoch_ms """)) { - statement.setString(1, key); - statement.setString(2, value == null ? "" : value); + statement.setString(1, normalize(currentOwnerCallsignRaw)); + statement.setString(2, normalize(currentOwnerLocator6)); + statement.setLong(3, System.currentTimeMillis()); statement.executeUpdate(); } } diff --git a/src/test/java/kst4contest/view/map/TerrainProfileCacheRepositoryTest.java b/src/test/java/kst4contest/view/map/TerrainProfileCacheRepositoryTest.java new file mode 100644 index 00000000..6720529d --- /dev/null +++ b/src/test/java/kst4contest/view/map/TerrainProfileCacheRepositoryTest.java @@ -0,0 +1,111 @@ +package kst4contest.view.map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the property that made the terrain cache useless with several operator + * profiles: it used to drop every cached profile whenever the configured callsign or + * locator changed. + */ +class TerrainProfileCacheRepositoryTest { + + private static final String USER_HOME_PROPERTY = "user.home"; + private static final String PROVIDER_ID = "test-provider"; + private static final int SAMPLE_COUNT = 3; + + @TempDir + Path temporaryHomeDirectory; + + private String originalUserHome; + + @BeforeEach + void redirectUserHomeToTemporaryDirectory() { + originalUserHome = System.getProperty(USER_HOME_PROPERTY); + System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString()); + } + + @AfterEach + void restoreUserHome() { + if (originalUserHome == null) { + System.clearProperty(USER_HOME_PROPERTY); + } else { + System.setProperty(USER_HOME_PROPERTY, originalUserHome); + } + } + + @Test + void profilesOfDifferentOwnersCoexistAndSurviveSwitchingBackAndForth() { + + TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository(); + + repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK", + SAMPLE_COUNT, PROVIDER_ID, profileData("station-a")); + + // Another operator profile with a different callsign and locator. + repository.save("DN9APW", "JN59AA", "DL0ABC", "JN49FK", + SAMPLE_COUNT, PROVIDER_ID, profileData("station-b")); + + Optional