diff --git a/src/org/kde/kdeconnect/Device.java b/src/org/kde/kdeconnect/Device.java index d0b8cc88..8aade2aa 100644 --- a/src/org/kde/kdeconnect/Device.java +++ b/src/org/kde/kdeconnect/Device.java @@ -371,7 +371,7 @@ public class Device implements BaseLink.PacketReceiver { @Override public void onPacketReceived(@NonNull NetworkPacket np) { - DeviceStats.countReceived(getDeviceId(), np.getType()); + DeviceStats.INSTANCE.countReceived(getDeviceId(), np.getType()); if (NetworkPacket.PACKET_TYPE_PAIR.equals(np.getType())) { Log.i("KDE/Device", "Pair packet"); @@ -519,7 +519,7 @@ public class Device implements BaseLink.PacketReceiver { } catch (IOException e) { e.printStackTrace(); } - DeviceStats.countSent(getDeviceId(), np.getType(), success); + DeviceStats.INSTANCE.countSent(getDeviceId(), np.getType(), success); if (success) break; } diff --git a/src/org/kde/kdeconnect/DeviceStats.java b/src/org/kde/kdeconnect/DeviceStats.java deleted file mode 100644 index 4b3586f6..00000000 --- a/src/org/kde/kdeconnect/DeviceStats.java +++ /dev/null @@ -1,198 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 Albert Vaca Cintora - * - * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL -*/ - -package org.kde.kdeconnect; - -import android.os.Build; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; -import androidx.annotation.VisibleForTesting; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.Iterator; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -public class DeviceStats { - - /** - * Keep 24 hours of events - */ - private static final long EVENT_KEEP_WINDOW_MILLIS = 24 * 60 * 60 * 1000; - - /** - * Delete old (>24 hours, see EVENT_KEEP_WINDOW_MILLIS) events every 6 hours - */ - private static final long CLEANUP_INTERVAL_MILLIS = EVENT_KEEP_WINDOW_MILLIS/4; - - private final static HashMap eventsByDevice = new HashMap<>(); - private static long nextCleanup = System.currentTimeMillis() + CLEANUP_INTERVAL_MILLIS; - - static class PacketStats { - public long createdAtMillis = System.currentTimeMillis(); - public HashMap> receivedByType = new HashMap<>(); - public HashMap> sentSuccessfulByType = new HashMap<>(); - public HashMap> sentFailedByType = new HashMap<>(); - - static class Summary { - final @NonNull String packetType; - int received = 0; - int sentSuccessful = 0; - int sentFailed = 0; - int total = 0; - - Summary(@NonNull String packetType) { - this.packetType = packetType; - } - } - - @RequiresApi(api = Build.VERSION_CODES.N) - public @NonNull Collection getSummaries() { - HashMap countsByType = new HashMap<>(); - for (Map.Entry> entry : receivedByType.entrySet()) { - Summary summary = countsByType.computeIfAbsent(entry.getKey(), Summary::new); - summary.received += entry.getValue().size(); - summary.total += entry.getValue().size(); - } - for (Map.Entry> entry : sentSuccessfulByType.entrySet()) { - Summary summary = countsByType.computeIfAbsent(entry.getKey(), Summary::new); - summary.sentSuccessful += entry.getValue().size(); - summary.total += entry.getValue().size(); - } - for (Map.Entry> entry : sentFailedByType.entrySet()) { - Summary summary = countsByType.computeIfAbsent(entry.getKey(), Summary::new); - summary.sentFailed += entry.getValue().size(); - summary.total += entry.getValue().size(); - } - return countsByType.values(); - } - } - - @RequiresApi(api = Build.VERSION_CODES.N) - public static @NonNull String getStatsForDevice(@NonNull String deviceId) { - - cleanupIfNeeded(); - - PacketStats packetStats = eventsByDevice.get(deviceId); - if (packetStats == null) { - return ""; - } - - StringBuilder ret = new StringBuilder(); - - long timeInMillis = System.currentTimeMillis() - packetStats.createdAtMillis; - if (timeInMillis > EVENT_KEEP_WINDOW_MILLIS) { - timeInMillis = EVENT_KEEP_WINDOW_MILLIS; - } - long hours = TimeUnit.MILLISECONDS.toHours(timeInMillis); - long minutes = TimeUnit.MILLISECONDS.toMinutes(timeInMillis) % 60; - ret.append("From last "); - ret.append(hours); - ret.append("h "); - ret.append(minutes); - ret.append("m\n\n"); - - ArrayList counts = new ArrayList<>(packetStats.getSummaries()); - Collections.sort(counts, (o1, o2) -> Integer.compare(o2.total, o1.total)); // Sort them by total number of events - - for (PacketStats.Summary count : counts) { - String name = count.packetType; - if (name.startsWith("kdeconnect.")) { - name = name.substring("kdeconnect.".length()); - } - ret.append(name); - ret.append("\n• "); - ret.append(count.received); - ret.append(" received\n• "); - ret.append(count.sentSuccessful + count.sentFailed); - ret.append(" sent ("); - ret.append(count.sentFailed); - ret.append(" failed)\n"); - } - - return ret.toString(); - } - - public static void countReceived(@NonNull String deviceId, @NonNull String packetType) { - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) { - return; // computeIfAbsent not present in API < 24 - } - synchronized (DeviceStats.class) { - eventsByDevice - .computeIfAbsent(deviceId, key -> new PacketStats()) - .receivedByType - .computeIfAbsent(packetType, key -> new ArrayList<>()) - .add(System.currentTimeMillis()); - } - cleanupIfNeeded(); - } - - public static void countSent(@NonNull String deviceId, @NonNull String packetType, boolean success) { - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) { - return; // computeIfAbsent not present in API < 24 - } - if (success) { - synchronized (DeviceStats.class) { - eventsByDevice - .computeIfAbsent(deviceId, key -> new PacketStats()) - .sentSuccessfulByType - .computeIfAbsent(packetType, key -> new ArrayList<>()) - .add(System.currentTimeMillis()); - } - } else { - synchronized (DeviceStats.class) { - eventsByDevice - .computeIfAbsent(deviceId, key -> new PacketStats()) - .sentFailedByType - .computeIfAbsent(packetType, key -> new ArrayList<>()) - .add(System.currentTimeMillis()); - } - } - cleanupIfNeeded(); - } - - private static void cleanupIfNeeded() { - final long cutoutTimestamp = System.currentTimeMillis() - EVENT_KEEP_WINDOW_MILLIS; - if (System.currentTimeMillis() > nextCleanup) { - synchronized (DeviceStats.class) { - Log.i("PacketStats", "Doing periodic cleanup"); - for (PacketStats de : eventsByDevice.values()) { - removeOldEvents(de.receivedByType, cutoutTimestamp); - removeOldEvents(de.sentFailedByType, cutoutTimestamp); - removeOldEvents(de.sentSuccessfulByType, cutoutTimestamp); - } - nextCleanup = System.currentTimeMillis() + CLEANUP_INTERVAL_MILLIS; - } - } - } - - @VisibleForTesting - static void removeOldEvents(HashMap> eventsByType, final long cutoutTimestamp) { - - Iterator>> iterator = eventsByType.entrySet().iterator(); - while (iterator.hasNext()) { - Map.Entry> entry = iterator.next(); - ArrayList events = entry.getValue(); - - int index = Collections.binarySearch(events, cutoutTimestamp); - if (index < 0) { - index = -(index + 1); // Convert the negative index to insertion point - } - - if (index < events.size()) { - events.subList(0, index).clear(); - } else { - iterator.remove(); // No element greater than the threshold - } - } - } - -} diff --git a/src/org/kde/kdeconnect/DeviceStats.kt b/src/org/kde/kdeconnect/DeviceStats.kt new file mode 100644 index 00000000..6dae08b6 --- /dev/null +++ b/src/org/kde/kdeconnect/DeviceStats.kt @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: 2023 Albert Vaca Cintora + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL +*/ +package org.kde.kdeconnect + +import android.annotation.SuppressLint +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.annotation.VisibleForTesting +import java.util.* +import java.util.concurrent.TimeUnit + +object DeviceStats { + /** + * Keep 24 hours of events + */ + private const val EVENT_KEEP_WINDOW_MILLIS: Long = 24 * 60 * 60 * 1000 + + /** + * Delete old (>24 hours, see EVENT_KEEP_WINDOW_MILLIS) events every 6 hours + */ + private const val CLEANUP_INTERVAL_MILLIS = EVENT_KEEP_WINDOW_MILLIS / 4 + + private val eventsByDevice: MutableMap = HashMap() + private var nextCleanup = System.currentTimeMillis() + CLEANUP_INTERVAL_MILLIS + + @RequiresApi(api = Build.VERSION_CODES.N) + fun getStatsForDevice(deviceId: String): String { + cleanupIfNeeded() + + val packetStats = eventsByDevice[deviceId] ?: return "" + + return buildString { + val timeInMillis = + minOf((System.currentTimeMillis() - packetStats.createdAtMillis), EVENT_KEEP_WINDOW_MILLIS) + val hours = TimeUnit.MILLISECONDS.toHours(timeInMillis) + val minutes = TimeUnit.MILLISECONDS.toMinutes(timeInMillis) % 60 + append("From last ") + append(hours) + append("h ") + append(minutes) + append("m\n\n") + + packetStats.summaries.stream().sorted { o1, o2 -> + o2.total compareTo o1.total // Sort them by total number of events + }.forEach { count -> + append(count.packetType.removePrefix("kdeconnect.")) + append("\n• ") + append(count.received) + append(" received\n• ") + append(count.sentSuccessful + count.sentFailed) + append(" sent (") + append(count.sentFailed) + append(" failed)\n") + } + } + } + + @SuppressLint("NewApi") // We use core library desugar + fun countReceived(deviceId: String, packetType: String) { + synchronized(DeviceStats::class.java) { + eventsByDevice + .computeIfAbsent(deviceId) { PacketStats() } + .receivedByType + .computeIfAbsent(packetType) { ArrayList() } + .add(System.currentTimeMillis()) + } + cleanupIfNeeded() + } + + @SuppressLint("NewApi") // We use core library desugar + fun countSent(deviceId: String, packetType: String, success: Boolean) { + if (success) { + synchronized(DeviceStats::class.java) { + eventsByDevice + .computeIfAbsent(deviceId) { PacketStats() } + .sentSuccessfulByType + .computeIfAbsent(packetType) { ArrayList() } + .add(System.currentTimeMillis()) + } + } else { + synchronized(DeviceStats::class.java) { + eventsByDevice + .computeIfAbsent(deviceId) { PacketStats() } + .sentFailedByType + .computeIfAbsent(packetType) { ArrayList() } + .add(System.currentTimeMillis()) + } + } + cleanupIfNeeded() + } + + private fun cleanupIfNeeded() { + val cutoutTimestamp = System.currentTimeMillis() - EVENT_KEEP_WINDOW_MILLIS + if (System.currentTimeMillis() > nextCleanup) { + synchronized(DeviceStats::class.java) { + Log.i("PacketStats", "Doing periodic cleanup") + for (de in eventsByDevice.values) { + removeOldEvents(de.receivedByType, cutoutTimestamp) + removeOldEvents(de.sentFailedByType, cutoutTimestamp) + removeOldEvents(de.sentSuccessfulByType, cutoutTimestamp) + } + nextCleanup = System.currentTimeMillis() + CLEANUP_INTERVAL_MILLIS + } + } + } + + @VisibleForTesting + fun removeOldEvents(eventsByType: HashMap>, cutoutTimestamp: Long) { + val iterator = eventsByType.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + val events = entry.value + + var index = Collections.binarySearch(events, cutoutTimestamp) + if (index < 0) { + index = -(index + 1) // Convert the negative index to insertion point + } + + if (index < events.size) { + events.subList(0, index).clear() + } else { + iterator.remove() // No element greater than the threshold + } + } + } + + internal class PacketStats { + val createdAtMillis: Long = System.currentTimeMillis() + val receivedByType: HashMap> = HashMap() + val sentSuccessfulByType: HashMap> = HashMap() + val sentFailedByType: HashMap> = HashMap() + + internal data class Summary( + val packetType: String, + var received: Int = 0, + var sentSuccessful: Int = 0, + var sentFailed: Int = 0, + var total: Int = 0 + ) + + @get:SuppressLint("NewApi") // We use core library desugar + val summaries: Collection + get() { + val countsByType: MutableMap = HashMap() + for ((key, value) in receivedByType) { + val summary = countsByType.computeIfAbsent(key) { packetType -> Summary(packetType) } + summary.received += value.size + summary.total += value.size + } + for ((key, value) in sentSuccessfulByType) { + val summary = countsByType.computeIfAbsent(key) { packetType -> Summary(packetType) } + summary.sentSuccessful += value.size + summary.total += value.size + } + for ((key, value) in sentFailedByType) { + val summary = countsByType.computeIfAbsent(key) { packetType -> Summary(packetType) } + summary.sentFailed += value.size + summary.total += value.size + } + return countsByType.values + } + } +} diff --git a/src/org/kde/kdeconnect/UserInterface/PluginSettingsActivity.java b/src/org/kde/kdeconnect/UserInterface/PluginSettingsActivity.java index f4c6db60..901350e7 100644 --- a/src/org/kde/kdeconnect/UserInterface/PluginSettingsActivity.java +++ b/src/org/kde/kdeconnect/UserInterface/PluginSettingsActivity.java @@ -104,7 +104,7 @@ public class PluginSettingsActivity return false; // PacketStats not working in API < 24 } menu.add(R.string.plugin_stats).setOnMenuItemClickListener(item -> { - String stats = DeviceStats.getStatsForDevice(deviceId); + String stats = DeviceStats.INSTANCE.getStatsForDevice(deviceId); AlertDialog alertDialog = new MaterialAlertDialogBuilder(PluginSettingsActivity.this) .setTitle(R.string.plugin_stats) .setPositiveButton(R.string.ok, (dialog, which) -> dialog.dismiss()) diff --git a/tests/org/kde/kdeconnect/DeviceStatsTest.java b/tests/org/kde/kdeconnect/DeviceStatsTest.java deleted file mode 100644 index 60a3ee16..00000000 --- a/tests/org/kde/kdeconnect/DeviceStatsTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package org.kde.kdeconnect; - -import org.junit.Assert; -import org.junit.Test; - -import java.util.ArrayList; -import java.util.HashMap; - -public class DeviceStatsTest { - - @Test - public void removeOldEvents_cutoutExists() { - final String key = "kdeconnect.ping"; - HashMap> eventsByType = new HashMap<>(); - ArrayList events = new ArrayList<>(); - eventsByType.put(key, events); - events.add(10L); - events.add(20L); - events.add(30L); - final long cutout = 20L; - DeviceStats.removeOldEvents(eventsByType, cutout); - ArrayList eventsAfter = eventsByType.get(key); - Assert.assertNotNull(eventsAfter); - Assert.assertEquals(2, eventsAfter.size()); - Assert.assertEquals(eventsAfter.get(0).longValue(), 20L); - Assert.assertEquals(eventsAfter.get(1).longValue(), 30L); - } - - @Test - public void removeOldEvents_cutoutDoesntExist() { - final String key = "kdeconnect.ping"; - HashMap> eventsByType = new HashMap<>(); - ArrayList events = new ArrayList<>(); - eventsByType.put(key, events); - events.add(10L); - events.add(20L); - events.add(30L); - final long cutout = 25L; - DeviceStats.removeOldEvents(eventsByType, cutout); - ArrayList eventsAfter = eventsByType.get(key); - Assert.assertNotNull(eventsAfter); - Assert.assertEquals(1, eventsAfter.size()); - Assert.assertEquals(eventsAfter.get(0).longValue(), 30L); - } - - @Test - public void removeOldEvents_OnlyOldEvents() { - final String key = "kdeconnect.ping"; - HashMap> eventsByType = new HashMap<>(); - ArrayList events = new ArrayList<>(); - eventsByType.put(key, events); - events.add(10L); - events.add(20L); - final long cutout = 25L; - DeviceStats.removeOldEvents(eventsByType, cutout); - ArrayList eventsAfter = eventsByType.get(key); - Assert.assertNull(eventsAfter); - } - - @Test - public void removeOldEvents_OnlyNewEvents() { - final String key = "kdeconnect.ping"; - HashMap> eventsByType = new HashMap<>(); - ArrayList events = new ArrayList<>(); - eventsByType.put(key, events); - events.add(10L); - final long cutout = 5L; - DeviceStats.removeOldEvents(eventsByType, cutout); - ArrayList eventsAfter = eventsByType.get(key); - Assert.assertNotNull(eventsAfter); - Assert.assertEquals(1, eventsAfter.size()); - Assert.assertEquals(eventsAfter.get(0).longValue(), 10L); - } -} diff --git a/tests/org/kde/kdeconnect/DeviceStatsTest.kt b/tests/org/kde/kdeconnect/DeviceStatsTest.kt new file mode 100644 index 00000000..fe7ef057 --- /dev/null +++ b/tests/org/kde/kdeconnect/DeviceStatsTest.kt @@ -0,0 +1,66 @@ +package org.kde.kdeconnect + +import org.junit.Assert +import org.junit.Test +import org.kde.kdeconnect.DeviceStats.removeOldEvents + +class DeviceStatsTest { + @Test + fun removeOldEvents_cutoutExists() { + val key = "kdeconnect.ping" + val eventsByType = HashMap>().apply { + val events = arrayListOf(10L, 20L, 30L) + put(key, events) + } + val cutout = 20L + removeOldEvents(eventsByType, cutout) + val eventsAfter = eventsByType[key]!! + Assert.assertNotNull(eventsAfter) + Assert.assertEquals(2, eventsAfter.size.toLong()) + Assert.assertEquals(eventsAfter[0], 20L) + Assert.assertEquals(eventsAfter[1], 30L) + } + + @Test + fun removeOldEvents_cutoutDoesntExist() { + val key = "kdeconnect.ping" + val eventsByType = HashMap>().apply { + val events = arrayListOf(10L, 20L, 30L) + put(key, events) + } + val cutout = 25L + removeOldEvents(eventsByType, cutout) + val eventsAfter = eventsByType[key]!! + Assert.assertNotNull(eventsAfter) + Assert.assertEquals(1, eventsAfter.size.toLong()) + Assert.assertEquals(eventsAfter[0], 30L) + } + + @Test + fun removeOldEvents_OnlyOldEvents() { + val key = "kdeconnect.ping" + val eventsByType = HashMap>().apply { + val events = arrayListOf(10L, 20L) + put(key, events) + } + val cutout = 25L + removeOldEvents(eventsByType, cutout) + val eventsAfter = eventsByType[key] + Assert.assertNull(eventsAfter) + } + + @Test + fun removeOldEvents_OnlyNewEvents() { + val key = "kdeconnect.ping" + val eventsByType = HashMap>().apply { + val events = arrayListOf(10L) + put(key, events) + } + val cutout = 5L + removeOldEvents(eventsByType, cutout) + val eventsAfter = eventsByType[key]!! + Assert.assertNotNull(eventsAfter) + Assert.assertEquals(1, eventsAfter.size.toLong()) + Assert.assertEquals(eventsAfter[0], 10L) + } +}