From 11cbc2c293b6e3e7f6b2d704bebe1377db7a016a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 26 Jun 2025 17:44:08 +0200 Subject: [PATCH] Extract and unit test MultipleTapToUnlock --- features/preferences/impl/build.gradle.kts | 1 + .../impl/root/PreferencesRootPresenter.kt | 4 +- .../utils/ShowDeveloperSettingsProvider.kt | 13 +++--- libraries/ui-utils/build.gradle.kts | 2 + .../libraries/ui/utils/MultipleTapToUnlock.kt | 42 +++++++++++++++++++ .../ui/utils/MultipleTapToUnlockTest.kt | 41 ++++++++++++++++++ 6 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlock.kt create mode 100644 libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlockTest.kt diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 6fb6e4a55b..fa7aeb4bca 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -66,6 +66,7 @@ dependencies { implementation(projects.libraries.permissions.api) implementation(projects.libraries.push.api) implementation(projects.libraries.pushproviders.api) + implementation(projects.libraries.uiUtils) implementation(projects.libraries.fullscreenintent.api) implementation(projects.features.rageshake.api) implementation(projects.features.lockscreen.api) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 15a55f41ca..6fd2d635e1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider @@ -49,6 +50,7 @@ class PreferencesRootPresenter @Inject constructor( ) : Presenter { @Composable override fun present(): PreferencesRootState { + val coroutineScope = rememberCoroutineScope() val matrixUser = matrixClient.userProfile.collectAsState() LaunchedEffect(Unit) { // Force a refresh of the profile @@ -103,7 +105,7 @@ class PreferencesRootPresenter @Inject constructor( fun handleEvent(event: PreferencesRootEvents) { when (event) { is PreferencesRootEvents.OnVersionInfoClick -> { - showDeveloperSettingsProvider.unlockDeveloperSettings() + showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope) } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/utils/ShowDeveloperSettingsProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/utils/ShowDeveloperSettingsProvider.kt index 6589fd1c66..2ed16d6582 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/utils/ShowDeveloperSettingsProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/utils/ShowDeveloperSettingsProvider.kt @@ -9,6 +9,8 @@ package io.element.android.features.preferences.impl.utils import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.ui.utils.MultipleTapToUnlock +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject @@ -19,18 +21,15 @@ class ShowDeveloperSettingsProvider @Inject constructor( companion object { const val DEVELOPER_SETTINGS_COUNTER = 7 } - private var counter = DEVELOPER_SETTINGS_COUNTER + + private val multipleTapToUnlock = MultipleTapToUnlock(DEVELOPER_SETTINGS_COUNTER) private val isDeveloperBuild = buildMeta.buildType != BuildType.RELEASE private val _showDeveloperSettings = MutableStateFlow(isDeveloperBuild) val showDeveloperSettings: StateFlow = _showDeveloperSettings - fun unlockDeveloperSettings() { - if (counter == 0) { - return - } - counter-- - if (counter == 0) { + fun unlockDeveloperSettings(scope: CoroutineScope) { + if (multipleTapToUnlock.unlock(scope)) { _showDeveloperSettings.value = true } } diff --git a/libraries/ui-utils/build.gradle.kts b/libraries/ui-utils/build.gradle.kts index 40530645dc..fc60dc277e 100644 --- a/libraries/ui-utils/build.gradle.kts +++ b/libraries/ui-utils/build.gradle.kts @@ -15,5 +15,7 @@ android { dependencies { testImplementation(libs.test.junit) testImplementation(libs.test.truth) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.truth) } } diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlock.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlock.kt new file mode 100644 index 0000000000..15a8b82eee --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlock.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +/** + * Returns true if the user has tapped [numberOfTapToUnlock] times in a short amount of time. + * The counter is reset after 2 seconds of inactivity. + * + * @param numberOfTapToUnlock The number of taps required to unlock. + */ +class MultipleTapToUnlock( + private val numberOfTapToUnlock: Int = 7, +) { + private var counter = numberOfTapToUnlock + private var currentJob: Job? = null + + fun unlock(scope: CoroutineScope): Boolean { + counter-- + currentJob?.cancel() + return if (counter > 0) { + currentJob = scope.launch { + delay(2.seconds) + // Reset counter if user is not fast enough + counter = numberOfTapToUnlock + } + false + } else { + true + } + } +} diff --git a/libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlockTest.kt b/libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlockTest.kt new file mode 100644 index 0000000000..1745f617d8 --- /dev/null +++ b/libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlockTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.ui.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class MultipleTapToUnlockTest { + @Test + fun `test multiple tap should unlock`() = runTest { + val sut = MultipleTapToUnlock(3) + assertThat(sut.unlock(backgroundScope)).isFalse() + assertThat(sut.unlock(backgroundScope)).isFalse() + assertThat(sut.unlock(backgroundScope)).isTrue() + assertThat(sut.unlock(backgroundScope)).isTrue() + // All next call returns true + advanceTimeBy(3.seconds) + assertThat(sut.unlock(backgroundScope)).isTrue() + } + @Test + fun `test waiting should reset counter`() = runTest { + val sut = MultipleTapToUnlock(3) + assertThat(sut.unlock(backgroundScope)).isFalse() + assertThat(sut.unlock(backgroundScope)).isFalse() + advanceTimeBy(3.seconds) + assertThat(sut.unlock(backgroundScope)).isFalse() + assertThat(sut.unlock(backgroundScope)).isFalse() + assertThat(sut.unlock(backgroundScope)).isTrue() + } +}