diff --git a/src/main/java/com/nextcloud/client/di/AppModule.java b/src/main/java/com/nextcloud/client/di/AppModule.java index 44faf419c7..780ed42d43 100644 --- a/src/main/java/com/nextcloud/client/di/AppModule.java +++ b/src/main/java/com/nextcloud/client/di/AppModule.java @@ -61,6 +61,7 @@ import com.owncloud.android.ui.activities.data.activities.RemoteActivitiesReposi import com.owncloud.android.ui.activities.data.files.FilesRepository; import com.owncloud.android.ui.activities.data.files.FilesServiceApiImpl; import com.owncloud.android.ui.activities.data.files.RemoteFilesRepository; +import com.nextcloud.client.utils.Throttler; import org.greenrobot.eventbus.EventBus; @@ -231,4 +232,9 @@ class AppModule { LocalBroadcastManager localBroadcastManager(Context context) { return LocalBroadcastManager.getInstance(context); } + + @Provides + Throttler throttler(Clock clock) { + return new Throttler(clock); + } } diff --git a/src/main/java/com/nextcloud/client/utils/Throttler.kt b/src/main/java/com/nextcloud/client/utils/Throttler.kt new file mode 100644 index 0000000000..bf424cd39a --- /dev/null +++ b/src/main/java/com/nextcloud/client/utils/Throttler.kt @@ -0,0 +1,48 @@ +/* + * Nextcloud Android client application + * + * @author Álvaro Brey Vilas + * Copyright (C) 2021 Álvaro Brey Vilas + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.nextcloud.client.utils + +import com.nextcloud.client.core.Clock + +/** + * Simple throttler that just discards new calls until interval has passed. + * + * @param clock the Clock to provide timestamps + */ +class Throttler(private val clock: Clock) { + + /** + * The interval, in milliseconds, between accepted calls + */ + @Suppress("MagicNumber") + var intervalMillis = 150L + private val timestamps: MutableMap = mutableMapOf() + + @Synchronized + fun run(key: String, runnable: Runnable) { + val time = clock.currentTime + val lastCallTimestamp = timestamps[key] ?: 0 + if (time - lastCallTimestamp > intervalMillis) { + runnable.run() + timestamps[key] = time + } + } +} diff --git a/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 7f7239e04a..3dd03626fd 100644 --- a/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -101,6 +101,7 @@ import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.utils.FileSortOrder; import com.owncloud.android.utils.FileStorageUtils; import com.owncloud.android.utils.MimeTypeUtil; +import com.nextcloud.client.utils.Throttler; import com.owncloud.android.utils.theme.ThemeColorUtils; import com.owncloud.android.utils.theme.ThemeFabUtils; import com.owncloud.android.utils.theme.ThemeToolbarUtils; @@ -180,6 +181,7 @@ public class OCFileListFragment extends ExtendedListFragment implements @Inject AppPreferences preferences; @Inject UserAccountManager accountManager; @Inject ClientFactory clientFactory; + @Inject Throttler throttler; protected FileFragment.ContainerActivity mContainerActivity; protected OCFile mFile; @@ -199,6 +201,7 @@ public class OCFileListFragment extends ExtendedListFragment implements protected String mLimitToMimeType; private FloatingActionButton mFabMain; + @Inject DeviceInfo deviceInfo; protected enum MenuItemAddRemove { @@ -518,20 +521,22 @@ public class OCFileListFragment extends ExtendedListFragment implements @Override public void onOverflowIconClicked(OCFile file, View view) { - PopupMenu popup = new PopupMenu(getActivity(), view); - popup.inflate(R.menu.item_file); - FileMenuFilter mf = new FileMenuFilter(mAdapter.getFiles().size(), - Collections.singleton(file), - mContainerActivity, getActivity(), - true, - accountManager.getUser()); - mf.filter(popup.getMenu(), true); - popup.setOnMenuItemClickListener(item -> { - Set checkedFiles = new HashSet<>(); - checkedFiles.add(file); - return onFileActionChosen(item, checkedFiles); + throttler.run("overflowClick", () -> { + PopupMenu popup = new PopupMenu(getActivity(), view); + popup.inflate(R.menu.item_file); + FileMenuFilter mf = new FileMenuFilter(mAdapter.getFiles().size(), + Collections.singleton(file), + mContainerActivity, getActivity(), + true, + accountManager.getUser()); + mf.filter(popup.getMenu(), true); + popup.setOnMenuItemClickListener(item -> { + Set checkedFiles = new HashSet<>(); + checkedFiles.add(file); + return onFileActionChosen(item, checkedFiles); + }); + popup.show(); }); - popup.show(); } @Override diff --git a/src/test/java/com/nextcloud/client/utils/ThrottlerTest.kt b/src/test/java/com/nextcloud/client/utils/ThrottlerTest.kt new file mode 100644 index 0000000000..072277d7bf --- /dev/null +++ b/src/test/java/com/nextcloud/client/utils/ThrottlerTest.kt @@ -0,0 +1,86 @@ +package com.nextcloud.client.utils + +import com.nextcloud.client.core.Clock +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.every + +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class ThrottlerTest { + companion object { + private const val KEY = "TEST" + } + + @MockK + lateinit var runnable: Runnable + + @MockK + lateinit var clock: Clock + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + every { runnable.run() } just Runs + } + + private fun runWithThrottler(throttler: Throttler) { + throttler.run(KEY, runnable) + } + + @Test + fun unchangingTime_multipleCalls_calledExactlyOnce() { + // given + every { clock.currentTime } returns 300 + + val sut = Throttler(clock).apply { + intervalMillis = 150 + } + + // when + repeat(10) { + runWithThrottler(sut) + } + + // then + verify(exactly = 1) { runnable.run() } + } + + @Test + fun spacedCalls_noThrottle() { + // given + val sut = Throttler(clock).apply { + intervalMillis = 150 + } + every { clock.currentTime } returnsMany listOf(200, 400, 600, 800) + + // when + repeat(4) { + runWithThrottler(sut) + } + + // then + verify(exactly = 4) { runnable.run() } + } + + @Test + fun mixedIntervals_sometimesThrottled() { + // given + val sut = Throttler(clock).apply { + intervalMillis = 150 + } + every { clock.currentTime } returnsMany listOf(200, 300, 400, 500) + + // when + repeat(4) { + runWithThrottler(sut) + } + + // then + verify(exactly = 2) { runnable.run() } + } +}