diff --git a/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java b/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java index a870fdd..223e00a 100644 --- a/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java +++ b/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java @@ -16,6 +16,10 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; /** * The contact list (TeamSpeak's "Friend/Foe manager"): who is a friend, who is @@ -32,6 +36,9 @@ import java.util.concurrent.CopyOnWriteArrayList; */ public final class ContactStore { + /** How long a sighting may wait before it is written. */ + static final long SEEN_SAVE_DELAY_S = 30; + /** Told after the list changed in any way; called on whatever thread made the change. */ public interface Listener { void onContactsChanged(); @@ -46,6 +53,13 @@ public final class ContactStore { /** Immutable snapshot for readers, replaced under the lock after every change. */ private volatile List snapshot = List.of(); private final List listeners = new CopyOnWriteArrayList<>(); + /** Set while a sighting waits for {@link #flush()}; any save takes it along. */ + private final AtomicBoolean savePending = new AtomicBoolean(); + private final ScheduledExecutorService saver = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "contacts-save"); + t.setDaemon(true); + return t; + }); ContactStore(File file) { this.file = file; @@ -89,6 +103,7 @@ public final class ContactStore { } public void save() { + savePending.set(false); List all = snapshot; StringBuilder sb = new StringBuilder(); for (Contact c : all) { @@ -211,7 +226,9 @@ public final class ContactStore { /** * Notes that a contact is online right now. A sighting within the same minute on the - * same server changes nothing, so a busy server does not rewrite the file per event. + * same server changes nothing. Sightings reach the file in one go, {@link + * #SEEN_SAVE_DELAY_S} later or with the next save, so a busy server does not rewrite it + * per event. */ public void markSeen(String uniqueId, String serverName, String serverAddress) { Contact c = find(uniqueId); @@ -226,11 +243,21 @@ public final class ContactStore { c.lastSeenServerName = orEmpty(serverName); c.lastSeenServerAddress = orEmpty(serverAddress); } - changed(); + if (savePending.compareAndSet(false, true)) saver.schedule(this::flush, SEEN_SAVE_DELAY_S, TimeUnit.SECONDS); + announce(); + } + + /** Writes sightings still waiting to be saved; for when the client goes away. */ + public void flush() { + if (savePending.get()) save(); } private void changed() { save(); + announce(); + } + + private void announce() { for (Listener l : listeners) l.onContactsChanged(); } diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index d7fb929..9ad1f55 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -995,8 +995,7 @@ public final class TeamspeakConnection implements TS3Listener { /** * Applies a client's contact entry to its playback — the automatic mute and the - * volume modifier — and records the sighting. Called when the client appears and - * whenever its entry is edited. + * volume modifier — and records the sighting. Called when the client appears. */ void applyContact(ClientEntry client) { Contact contact = applyContactPlayback(client); @@ -1016,12 +1015,18 @@ public final class TeamspeakConnection implements TS3Listener { /** * Re-applies every contact entry to the clients on the server, after the list changed. - * A client that just became a contact is also marked as seen; the store only reports - * that as a change once, so the re-application it triggers stops there. + * A client that just became a contact is also marked as seen. One seen before keeps + * where it was seen: with it on two servers, each re-application would claim it for its + * own server, which is a change that triggers the other's re-application, and so on. */ public void applyContacts() { if (!connected) return; - for (ClientEntry c : model.allClients()) applyContact(c); + for (ClientEntry c : model.allClients()) { + Contact contact = applyContactPlayback(c); + if (contact != null && contact.lastSeen == null) { + contacts.markSeen(c.uniqueId, model.getServerName(), serverHost + ":" + serverPort); + } + } } /** Remembers a per-client volume, in the contact entry when the client has one. */ diff --git a/ts3-client/core/src/test/java/com/ts3client/contacts/ContactStoreTest.java b/ts3-client/core/src/test/java/com/ts3client/contacts/ContactStoreTest.java index 2f48a2d..11cb0fb 100644 --- a/ts3-client/core/src/test/java/com/ts3client/contacts/ContactStoreTest.java +++ b/ts3-client/core/src/test/java/com/ts3client/contacts/ContactStoreTest.java @@ -80,6 +80,28 @@ class ContactStoreTest { assertEquals("other:9987", a.lastSeenServerAddress); } + @Test + void sightingsWaitForTheNextSave() throws Exception { + File file = new File(dir, "s.txt"); + ContactStore store = new ContactStore(file); + store.add("a=", "A", ContactType.NEUTRAL, new ContactDefaults()); + long written = file.lastModified(); + String before = Files.readString(file.toPath(), StandardCharsets.UTF_8); + + store.markSeen("a=", "Server", "host:9987"); + assertEquals(before, Files.readString(file.toPath(), StandardCharsets.UTF_8)); + store.flush(); + assertTrue(Files.readString(file.toPath(), StandardCharsets.UTF_8).contains("LastSeenServerAddress=host:9987")); + + // An edit's save takes a waiting sighting along; the flush then has nothing left to write. + store.markSeen("a=", "Other", "other:9987"); + store.put(store.find("a=")); + assertTrue(Files.readString(file.toPath(), StandardCharsets.UTF_8).contains("LastSeenServerAddress=other:9987")); + assertTrue(file.setLastModified(written)); + store.flush(); + assertEquals(written, file.lastModified()); + } + @Test void mergeAddsNewAndOverwritesExisting() { ContactStore store = new ContactStore(new File(dir, "m.txt")); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index e3e061b..a5d0268 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -766,6 +766,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { // Already shutting down (hook itself is running); nothing to remove. } sessions.shutdown(); + contacts.flush(); soundPlayer.shutdown(); hotkeys.dispose(); if (tray != null) tray.dispose();