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
22 changed files with 170 additions and 30 deletions

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ fun anOnBoardingState(
canLoginWithQrCode: Boolean = false,
canCreateAccount: Boolean = false,
canReportBug: Boolean = false,
version: String = "1.0.0",
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
eventSink: (OnBoardingEvents) -> Unit = {},
) = OnBoardingState(
@@ -39,6 +40,7 @@ fun anOnBoardingState(
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = canCreateAccount,
canReportBug = canReportBug,
version = version,
loginMode = loginMode,
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.
Text(
modifier = Modifier
.padding(16.dp)
.clickable(onClick = onReportProblem),
.clickable(onClick = onReportProblem)
.padding(16.dp),
text = stringResource(id = CommonStrings.common_report_a_problem),
style = ElementTheme.typography.fontBodySmRegular,
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_title">"Welcome back!"</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_to">"Sign in to %1$s"</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.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
assertThat(initialState.canReportBug).isTrue()
assertThat(initialState.canReportBug).isFalse()
assertThat(awaitItem().canLoginWithQrCode).isTrue()
}
}
@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(
rageshakeFeatureAvailability = { false },
)
presenter.test {
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.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)

View File

@@ -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<PreferencesRootState> {
@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)
}
}
}

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.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<Boolean> = _showDeveloperSettings
fun unlockDeveloperSettings() {
if (counter == 0) {
return
}
counter--
if (counter == 0) {
fun unlockDeveloperSettings(scope: CoroutineScope) {
if (multipleTapToUnlock.unlock(scope)) {
_showDeveloperSettings.value = true
}
}

View File

@@ -15,5 +15,7 @@ android {
dependencies {
testImplementation(libs.test.junit)
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()
}
}