Merge pull request #4944 from element-hq/feature/bma/version

Replace the Report a problem button with the app's version on the on boading screen.
This commit is contained in:
Benoit Marty 2025-06-27 14:45:18 +02:00 committed by GitHub
commit d065c30d49
22 changed files with 170 additions and 30 deletions

View file

@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.qrcode) implementation(projects.libraries.qrcode)
implementation(projects.libraries.oidc.api) implementation(projects.libraries.oidc.api)
implementation(projects.libraries.uiUtils)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
implementation(platform(libs.network.retrofit.bom)) implementation(platform(libs.network.retrofit.bom))
implementation(libs.androidx.webkit) implementation(libs.androidx.webkit)

View file

@ -12,5 +12,6 @@ sealed interface OnBoardingEvents {
val defaultAccountProvider: String val defaultAccountProvider: String
) : OnBoardingEvents ) : OnBoardingEvents
data object OnVersionClick : OnBoardingEvents
data object ClearError : OnBoardingEvents data object ClearError : OnBoardingEvents
} }

View file

@ -9,9 +9,12 @@ package io.element.android.features.login.impl.screens.onboarding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -24,6 +27,7 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.ui.utils.MultipleTapToUnlock
class OnBoardingPresenter @AssistedInject constructor( class OnBoardingPresenter @AssistedInject constructor(
@Assisted private val params: OnBoardingNode.Params, @Assisted private val params: OnBoardingNode.Params,
@ -40,6 +44,8 @@ class OnBoardingPresenter @AssistedInject constructor(
): OnBoardingPresenter ): OnBoardingPresenter
} }
private val multipleTapToUnlock = MultipleTapToUnlock()
@Composable @Composable
override fun present(): OnBoardingState { override fun present(): OnBoardingState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
@ -70,6 +76,7 @@ class OnBoardingPresenter @AssistedInject constructor(
featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin) featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin)
} }
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() } val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
var showReportBug by rememberSaveable { mutableStateOf(false) }
val loginMode by loginHelper.collectLoginMode() val loginMode by loginHelper.collectLoginMode()
@ -82,6 +89,13 @@ class OnBoardingPresenter @AssistedInject constructor(
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null }, loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
) )
OnBoardingEvents.ClearError -> loginHelper.clearError() OnBoardingEvents.ClearError -> loginHelper.clearError()
OnBoardingEvents.OnVersionClick -> {
if (canReportBug) {
if (multipleTapToUnlock.unlock(localCoroutineScope)) {
showReportBug = true
}
}
}
} }
} }
@ -91,8 +105,9 @@ class OnBoardingPresenter @AssistedInject constructor(
mustChooseAccountProvider = mustChooseAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider,
canLoginWithQrCode = canLoginWithQrCode, canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = defaultAccountProvider == null && canConnectToAnyHomeserver && OnBoardingConfig.CAN_CREATE_ACCOUNT, canCreateAccount = defaultAccountProvider == null && canConnectToAnyHomeserver && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug, canReportBug = canReportBug && showReportBug,
loginMode = loginMode, loginMode = loginMode,
version = buildMeta.versionName,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -17,6 +17,7 @@ data class OnBoardingState(
val canLoginWithQrCode: Boolean, val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean, val canCreateAccount: Boolean,
val canReportBug: Boolean, val canReportBug: Boolean,
val version: String,
val loginMode: AsyncData<LoginMode>, val loginMode: AsyncData<LoginMode>,
val eventSink: (OnBoardingEvents) -> Unit, val eventSink: (OnBoardingEvents) -> Unit,
) { ) {

View file

@ -30,6 +30,7 @@ fun anOnBoardingState(
canLoginWithQrCode: Boolean = false, canLoginWithQrCode: Boolean = false,
canCreateAccount: Boolean = false, canCreateAccount: Boolean = false,
canReportBug: Boolean = false, canReportBug: Boolean = false,
version: String = "1.0.0",
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized, loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
eventSink: (OnBoardingEvents) -> Unit = {}, eventSink: (OnBoardingEvents) -> Unit = {},
) = OnBoardingState( ) = OnBoardingState(
@ -39,6 +40,7 @@ fun anOnBoardingState(
canLoginWithQrCode = canLoginWithQrCode, canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = canCreateAccount, canCreateAccount = canCreateAccount,
canReportBug = canReportBug, canReportBug = canReportBug,
version = version,
loginMode = loginMode, loginMode = loginMode,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -202,12 +202,23 @@ private fun OnBoardingButtons(
// Add a report problem text button. Use a Text since we need a special theme here. // Add a report problem text button. Use a Text since we need a special theme here.
Text( Text(
modifier = Modifier modifier = Modifier
.padding(16.dp) .clickable(onClick = onReportProblem)
.clickable(onClick = onReportProblem), .padding(16.dp),
text = stringResource(id = CommonStrings.common_report_a_problem), text = stringResource(id = CommonStrings.common_report_a_problem),
style = ElementTheme.typography.fontBodySmRegular, style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
) )
} else {
Text(
modifier = Modifier
.clickable {
state.eventSink(OnBoardingEvents.OnVersionClick)
}
.padding(16.dp),
text = stringResource(id = R.string.screen_onboarding_app_version, state.version),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
} }
} }
} }

View file

@ -34,6 +34,7 @@
<string name="screen_login_subtitle">"Matrix is an open network for secure, decentralised communication."</string> <string name="screen_login_subtitle">"Matrix is an open network for secure, decentralised communication."</string>
<string name="screen_login_title">"Welcome back!"</string> <string name="screen_login_title">"Welcome back!"</string>
<string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string> <string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string>
<string name="screen_onboarding_app_version">"Version %1$s"</string>
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</string> <string name="screen_onboarding_sign_in_manually">"Sign in manually"</string>
<string name="screen_onboarding_sign_in_to">"Sign in to %1$s"</string> <string name="screen_onboarding_sign_in_to">"Sign in to %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string> <string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string>

View file

@ -83,19 +83,40 @@ class OnBoardingPresenterTest {
assertThat(initialState.canLoginWithQrCode).isFalse() assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B") assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT) assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
assertThat(initialState.canReportBug).isTrue() assertThat(initialState.canReportBug).isFalse()
assertThat(awaitItem().canLoginWithQrCode).isTrue() assertThat(awaitItem().canLoginWithQrCode).isTrue()
} }
} }
@Test @Test
fun `present - rageshake not available`() = runTest { fun `present - clicking on version 7 times has no effect if rageshake not available`() = runTest {
val presenter = createPresenter( val presenter = createPresenter(
rageshakeFeatureAvailability = { false }, rageshakeFeatureAvailability = { false },
) )
presenter.test { presenter.test {
skipItems(1) skipItems(1)
assertThat(awaitItem().canReportBug).isFalse() awaitItem().also { state ->
assertThat(state.canReportBug).isFalse()
repeat(7) {
state.eventSink(OnBoardingEvents.OnVersionClick)
}
}
expectNoEvents()
}
}
@Test
fun `present - clicking on version 7 times will reveal the report a problem button`() = runTest {
val presenter = createPresenter()
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.canReportBug).isFalse()
repeat(7) {
state.eventSink(OnBoardingEvents.OnVersionClick)
}
}
assertThat(awaitItem().canReportBug).isTrue()
} }
} }

View file

@ -66,6 +66,7 @@ dependencies {
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.push.api) implementation(projects.libraries.push.api)
implementation(projects.libraries.pushproviders.api) implementation(projects.libraries.pushproviders.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.fullscreenintent.api) implementation(projects.libraries.fullscreenintent.api)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.features.lockscreen.api) implementation(projects.features.lockscreen.api)

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
@ -49,6 +50,7 @@ class PreferencesRootPresenter @Inject constructor(
) : Presenter<PreferencesRootState> { ) : Presenter<PreferencesRootState> {
@Composable @Composable
override fun present(): PreferencesRootState { override fun present(): PreferencesRootState {
val coroutineScope = rememberCoroutineScope()
val matrixUser = matrixClient.userProfile.collectAsState() val matrixUser = matrixClient.userProfile.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Force a refresh of the profile // Force a refresh of the profile
@ -103,7 +105,7 @@ class PreferencesRootPresenter @Inject constructor(
fun handleEvent(event: PreferencesRootEvents) { fun handleEvent(event: PreferencesRootEvents) {
when (event) { when (event) {
is PreferencesRootEvents.OnVersionInfoClick -> { is PreferencesRootEvents.OnVersionInfoClick -> {
showDeveloperSettingsProvider.unlockDeveloperSettings() showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope)
} }
} }
} }

View file

@ -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.BuildMeta
import io.element.android.libraries.core.meta.BuildType 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject import javax.inject.Inject
@ -19,18 +21,15 @@ class ShowDeveloperSettingsProvider @Inject constructor(
companion object { companion object {
const val DEVELOPER_SETTINGS_COUNTER = 7 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 isDeveloperBuild = buildMeta.buildType != BuildType.RELEASE
private val _showDeveloperSettings = MutableStateFlow(isDeveloperBuild) private val _showDeveloperSettings = MutableStateFlow(isDeveloperBuild)
val showDeveloperSettings: StateFlow<Boolean> = _showDeveloperSettings val showDeveloperSettings: StateFlow<Boolean> = _showDeveloperSettings
fun unlockDeveloperSettings() { fun unlockDeveloperSettings(scope: CoroutineScope) {
if (counter == 0) { if (multipleTapToUnlock.unlock(scope)) {
return
}
counter--
if (counter == 0) {
_showDeveloperSettings.value = true _showDeveloperSettings.value = true
} }
} }

View file

@ -15,5 +15,7 @@ android {
dependencies { dependencies {
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
} }
} }

View file

@ -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
}
}
}

View file

@ -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()
}
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0ab623f806fc90bef41beccc5d5f444a882e6634cdadecbbc341e10369bbcfdb oid sha256:f8dc4806d2d363c326ac777011b95a98111e69d8f7acaccf926dc959bf8e9636
size 315380 size 311242

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b73088b5af32e47d18d6961fe5622411f5342388d8a0d78c8f3fb5e27213146a oid sha256:0b55b1c3f2f76ac40cba5f7d49d897a4ffe22d6718444ca8a1197173c660f182
size 315116 size 306017

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:24735f133066c55c6d88b7f9930ad983cca431611c7e6833ca5a4d657eeb14a5 oid sha256:644e67fd8ccb1754ba4bfec6de5bc8c75a6603b75faa96847076cc27ad18804b
size 313116 size 309657

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1d02a7ff9ff40f73d8c72b9378e10ba1f6580524cbdefcf290066b8047fdc33c oid sha256:bcc26d88db59b7499a9e1894608831b418a8088b248c5fb390a43ba8cd5aa319
size 307633 size 304249

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f5ac9f2f168b895e8262181f789b0fe2ad2c97b1296a50c1453879438bfe436a oid sha256:344fa6743ab2f43819abf6045d4d98ef0ff91f6b31e6de4a87235d6b96160e53
size 395942 size 392425

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f7c1c7d34935986d01113069c25849fc860133de582fe94ee63f2e28b181e87f oid sha256:ed48740f02725790f7de7a867d237449c07148367dc32ef7fbbd7b40d0ed9a4c
size 397378 size 379715

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a11f8383fc481255fb3fa6f1a5475d23c13c0460c33b04bc190cf3b5931893a3 oid sha256:d6a6c752f1de16c4c64987d8f603d76ec0ed344b59d335b71004ce85500d852e
size 395108 size 383032

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:36424ccefbbbd35201800dbcbb743a2d22aa6fe7a606054fc3acf8b98aead272 oid sha256:4b9101e8d1502445e3997230800ac1cd4adc50a5392cebdcdc4dfc3e5fe56abc
size 381588 size 364584