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);
+ }
+}