From 1ccf15010e443f584a20a631b76378f89f079aa2 Mon Sep 17 00:00:00 2001 From: Albert Vaca Cintora Date: Sat, 27 May 2023 00:49:38 +0200 Subject: [PATCH] Compute total number of packets sent and received by type --- res/values/strings.xml | 2 + src/org/kde/kdeconnect/Device.java | 5 +- src/org/kde/kdeconnect/PacketStats.java | 163 ++++++++++++++++++ .../UserInterface/PluginSettingsActivity.java | 23 +++ tests/org/kde/kdeconnect/PacketStatsTest.java | 77 +++++++++ 5 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/org/kde/kdeconnect/PacketStats.java create mode 100644 tests/org/kde/kdeconnect/PacketStatsTest.java diff --git a/res/values/strings.xml b/res/values/strings.xml index 33935d5a..afd245d3 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -538,4 +538,6 @@ Send clipboard Tap to execute + Plugin stats + diff --git a/src/org/kde/kdeconnect/Device.java b/src/org/kde/kdeconnect/Device.java index 5f9309f8..9216bb28 100644 --- a/src/org/kde/kdeconnect/Device.java +++ b/src/org/kde/kdeconnect/Device.java @@ -529,7 +529,9 @@ public class Device implements BaseLink.PacketReceiver { } @Override - public void onPacketReceived(NetworkPacket np) { + public void onPacketReceived(@NonNull NetworkPacket np) { + + PacketStats.countReceived(getDeviceId(), np.getType()); if (NetworkPacket.PACKET_TYPE_PAIR.equals(np.getType())) { @@ -692,6 +694,7 @@ public class Device implements BaseLink.PacketReceiver { } catch (IOException e) { e.printStackTrace(); } + PacketStats.countSent(getDeviceId(), np.getType(), success); if (success) break; } diff --git a/src/org/kde/kdeconnect/PacketStats.java b/src/org/kde/kdeconnect/PacketStats.java new file mode 100644 index 00000000..c25cdf98 --- /dev/null +++ b/src/org/kde/kdeconnect/PacketStats.java @@ -0,0 +1,163 @@ +package org.kde.kdeconnect; + +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +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 PacketStats { + + private static final long EVENT_KEEP_WINDOW_MILLIS = 24 * 60 * 60 * 1000; // Keep 24 hours of events + private static final long CLEANUP_INTERVAL_MILLIS = EVENT_KEEP_WINDOW_MILLIS/4; // Delete old (>24 hours) events every 6 hours + + static class DeviceEvents { + public long createdAtMillis = System.currentTimeMillis(); + public HashMap> receivedByType = new HashMap<>(); + public HashMap> sentSuccessfulByType = new HashMap<>(); + public HashMap> sentFailedByType = new HashMap<>(); + + static class Counts { + @NonNull String packetType; + int received = 0; + int sentSuccessful = 0; + int sentFailed = 0; + int total = 0; + + Counts(@NonNull String packetType) { + this.packetType = packetType; + } + } + + @RequiresApi(api = Build.VERSION_CODES.N) + public @NonNull Collection getCounts() { + HashMap countsByType = new HashMap<>(); + for (Map.Entry> entry : receivedByType.entrySet()) { + Counts counts = countsByType.computeIfAbsent(entry.getKey(), Counts::new); + counts.received += entry.getValue().size(); + counts.total += entry.getValue().size(); + } + for (Map.Entry> entry : sentSuccessfulByType.entrySet()) { + Counts counts = countsByType.computeIfAbsent(entry.getKey(), Counts::new); + counts.sentSuccessful += entry.getValue().size(); + counts.total += entry.getValue().size(); + } + for (Map.Entry> entry : sentFailedByType.entrySet()) { + Counts counts = countsByType.computeIfAbsent(entry.getKey(), Counts::new); + counts.sentFailed += entry.getValue().size(); + counts.total += entry.getValue().size(); + } + return countsByType.values(); + } + } + + private final static HashMap eventsByDevice = new HashMap<>(); + private static long nextCleanup = System.currentTimeMillis() + CLEANUP_INTERVAL_MILLIS; + + @RequiresApi(api = Build.VERSION_CODES.N) + public static @NonNull String getStatsForDevice(@NonNull String deviceId) { + + cleanupIfNeeded(); + + DeviceEvents deviceEvents = eventsByDevice.get(deviceId); + if (deviceEvents == null) { + return ""; + } + + StringBuilder ret = new StringBuilder(); + + long timeInMillis = System.currentTimeMillis() - deviceEvents.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<>(deviceEvents.getCounts()); + Collections.sort(counts, (o1, o2) -> Integer.compare(o2.total, o1.total)); // Sort them by total number of events + + for (DeviceEvents.Counts 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 + } + eventsByDevice.computeIfAbsent(deviceId, key -> new DeviceEvents()).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) { + eventsByDevice.computeIfAbsent(deviceId, key -> new DeviceEvents()).sentSuccessfulByType.computeIfAbsent(packetType, key -> new ArrayList<>()).add(System.currentTimeMillis()); + } else { + eventsByDevice.computeIfAbsent(deviceId, key ->new DeviceEvents()).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) { + Log.i("PacketStats", "Doing periodic cleanup"); + for (DeviceEvents de : eventsByDevice.values()) { + removeOldEvents(de.receivedByType, cutoutTimestamp); + removeOldEvents(de.sentFailedByType, cutoutTimestamp); + removeOldEvents(de.sentSuccessfulByType, cutoutTimestamp); + } + nextCleanup = System.currentTimeMillis() + CLEANUP_INTERVAL_MILLIS; + } + } + + 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/UserInterface/PluginSettingsActivity.java b/src/org/kde/kdeconnect/UserInterface/PluginSettingsActivity.java index 79d1f7fa..126b56c7 100644 --- a/src/org/kde/kdeconnect/UserInterface/PluginSettingsActivity.java +++ b/src/org/kde/kdeconnect/UserInterface/PluginSettingsActivity.java @@ -6,15 +6,20 @@ package org.kde.kdeconnect.UserInterface; +import android.os.Build; import android.os.Bundle; +import android.view.Menu; import android.view.MenuItem; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + import org.kde.kdeconnect.Device; import org.kde.kdeconnect.KdeConnect; +import org.kde.kdeconnect.PacketStats; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect_tp.R; @@ -88,6 +93,24 @@ public class PluginSettingsActivity return super.onOptionsItemSelected(item); } + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + menu.clear(); + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) { + return false; // PacketStats not working in API < 24 + } + menu.add(R.string.plugin_stats).setOnMenuItemClickListener(item -> { + String stats = PacketStats.getStatsForDevice(deviceId); + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(PluginSettingsActivity.this); + builder.setTitle(R.string.plugin_stats); + builder.setPositiveButton(R.string.ok, (dialog, which) -> dialog.dismiss()); + builder.setMessage(stats); + builder.show(); + return true; + }); + return true; + } @Override public void onStartPluginSettingsFragment(Plugin plugin) { diff --git a/tests/org/kde/kdeconnect/PacketStatsTest.java b/tests/org/kde/kdeconnect/PacketStatsTest.java new file mode 100644 index 00000000..0aef8d02 --- /dev/null +++ b/tests/org/kde/kdeconnect/PacketStatsTest.java @@ -0,0 +1,77 @@ +package org.kde.kdeconnect; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.modules.junit4.PowerMockRunner; + +import java.util.ArrayList; +import java.util.HashMap; + +@RunWith(PowerMockRunner.class) +public class PacketStatsTest { + + @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; + PacketStats.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; + PacketStats.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; + PacketStats.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; + PacketStats.removeOldEvents(eventsByType, cutout); + ArrayList eventsAfter = eventsByType.get(key); + Assert.assertNotNull(eventsAfter); + Assert.assertEquals(1, eventsAfter.size()); + Assert.assertEquals(eventsAfter.get(0).longValue(), 10L); + } +}