diff --git a/changelog.d/3020.misc b/changelog.d/3020.misc new file mode 100644 index 0000000000..8d9665917d --- /dev/null +++ b/changelog.d/3020.misc @@ -0,0 +1 @@ +Enable hidden access to developer options in release mode apps. diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index 69bd3d64bc..e9c2378abc 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -36,6 +36,8 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags @@ -52,6 +54,7 @@ class DeveloperSettingsPresenter @Inject constructor( private val clearCacheUseCase: ClearCacheUseCase, private val rageshakePresenter: RageshakePreferencesPresenter, private val appPreferencesStore: AppPreferencesStore, + private val buildMeta: BuildMeta, ) : Presenter { @Composable override fun present(): DeveloperSettingsState { @@ -76,6 +79,14 @@ class DeveloperSettingsPresenter @Inject constructor( LaunchedEffect(Unit) { FeatureFlags.entries .filter { it.isFinished.not() } + .run { + // Never display room directory search in release builds for Play Store + if (buildMeta.flavorDescription == "GooglePlay" && buildMeta.buildType == BuildType.RELEASE) { + filterNot { it.key == FeatureFlags.RoomDirectorySearch.key } + } else { + this + } + } .forEach { feature -> features[feature.key] = feature enabledFeatures[feature.key] = featureFlagService.isFeatureEnabled(feature) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt new file mode 100644 index 0000000000..64959e13c5 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.root + +sealed interface PreferencesRootEvents { + data object OnVersionInfoClick : PreferencesRootEvents +} 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 86992d4ac7..48fc3b7f9d 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 @@ -25,8 +25,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import io.element.android.features.logout.api.direct.DirectLogoutPresenter +import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -46,12 +46,12 @@ class PreferencesRootPresenter @Inject constructor( private val matrixClient: MatrixClient, private val sessionVerificationService: SessionVerificationService, private val analyticsService: AnalyticsService, - private val buildType: BuildType, private val versionFormatter: VersionFormatter, private val snackbarDispatcher: SnackbarDispatcher, private val featureFlagService: FeatureFlagService, private val indicatorService: IndicatorService, private val directLogoutPresenter: DirectLogoutPresenter, + private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider, ) : Presenter { @Composable override fun present(): PreferencesRootState { @@ -97,7 +97,16 @@ class PreferencesRootPresenter @Inject constructor( initAccountManagementUrl(accountManagementUrl, devicesManagementUrl) } - val showDeveloperSettings = buildType != BuildType.RELEASE + val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState() + + fun handleEvent(event: PreferencesRootEvents) { + when (event) { + is PreferencesRootEvents.OnVersionInfoClick -> { + showDeveloperSettingsProvider.unlockDeveloperSettings() + } + } + } + return PreferencesRootState( myUser = matrixUser.value, version = versionFormatter.get(), @@ -113,6 +122,7 @@ class PreferencesRootPresenter @Inject constructor( showBlockedUsersItem = showBlockedUsersItem, directLogoutState = directLogoutState, snackbarMessage = snackbarMessage, + eventSink = ::handleEvent, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index 336690638d..27f8934985 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -35,4 +35,5 @@ data class PreferencesRootState( val showBlockedUsersItem: Boolean, val directLogoutState: DirectLogoutState, val snackbarMessage: SnackbarMessage?, + val eventSink: (PreferencesRootEvents) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index 3373cb6ba0..2e75034c01 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun aPreferencesRootState( myUser: MatrixUser, + eventSink: (PreferencesRootEvents) -> Unit = { _ -> }, ) = PreferencesRootState( myUser = myUser, version = "Version 1.1 (1)", @@ -38,4 +39,5 @@ fun aPreferencesRootState( showBlockedUsersItem = true, snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), directLogoutState = aDirectLogoutState(), + eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 3ec69f25bf..c11e348db5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -18,10 +18,10 @@ package io.element.android.features.preferences.impl.root import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -112,6 +112,11 @@ fun PreferencesRootView( Footer( version = state.version, deviceId = state.deviceId, + onClick = if (!state.showDeveloperSettings) { + { state.eventSink(PreferencesRootEvents.OnVersionInfoClick) } + } else { + null + } ) } } @@ -231,9 +236,10 @@ private fun ColumnScope.GeneralSection( } @Composable -private fun Footer( +private fun ColumnScope.Footer( version: String, - deviceId: String? + deviceId: String?, + onClick: (() -> Unit)?, ) { val text = remember(version, deviceId) { buildString { @@ -246,8 +252,10 @@ private fun Footer( } Text( modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 40.dp, bottom = 24.dp), + .align(Alignment.CenterHorizontally) + .padding(top = 16.dp) + .clickable(enabled = onClick != null, onClick = onClick ?: {}) + .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 24.dp), textAlign = TextAlign.Center, text = text, style = ElementTheme.typography.fontBodySmRegular, 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 new file mode 100644 index 0000000000..de1ab8071b --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/utils/ShowDeveloperSettingsProvider.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +class ShowDeveloperSettingsProvider @Inject constructor( + buildMeta: BuildMeta, +) { + companion object { + const val DEVELOPER_SETTINGS_COUNTER = 7 + } + private var counter = 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) { + _showDeveloperSettings.value = true + } + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index 6908ee3c9d..113125d950 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -27,8 +27,11 @@ import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePr import io.element.android.features.rageshake.test.rageshake.FakeRageShake import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.awaitLastSequentialItem @@ -73,6 +76,19 @@ class DeveloperSettingsPresenterTest { } } + @Test + fun `present - ensures Room directory search is not present on release Google Play builds`() = runTest { + val buildMeta = aBuildMeta(buildType = BuildType.RELEASE, flavorDescription = "GooglePlay") + val presenter = createDeveloperSettingsPresenter(buildMeta = buildMeta) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val state = awaitLastSequentialItem() + assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - ensures state is updated when enabled feature event is triggered`() = runTest { val presenter = createDeveloperSettingsPresenter() @@ -150,6 +166,7 @@ class DeveloperSettingsPresenterTest { clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(), rageshakePresenter: DefaultRageshakePreferencesPresenter = DefaultRageshakePreferencesPresenter(FakeRageShake(), FakeRageshakeDataStore()), preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), + buildMeta: BuildMeta = aBuildMeta(), ): DeveloperSettingsPresenter { return DeveloperSettingsPresenter( featureFlagService = featureFlagService, @@ -157,6 +174,7 @@ class DeveloperSettingsPresenterTest { clearCacheUseCase = clearCacheUseCase, rageshakePresenter = rageshakePresenter, appPreferencesStore = preferencesStore, + buildMeta = buildMeta, ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 8bc6e92328..94b7f5c99e 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -23,6 +23,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.logout.api.direct.DirectLogoutPresenter import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -32,6 +33,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.services.analytics.test.FakeAnalyticsService @@ -53,24 +55,7 @@ class PreferencesRootPresenterTest { @Test fun `present - initial state`() = runTest { val matrixClient = FakeMatrixClient() - val sessionVerificationService = FakeSessionVerificationService() - val presenter = PreferencesRootPresenter( - matrixClient = matrixClient, - sessionVerificationService = sessionVerificationService, - analyticsService = FakeAnalyticsService(), - buildType = BuildType.DEBUG, - versionFormatter = FakeVersionFormatter(), - snackbarDispatcher = SnackbarDispatcher(), - featureFlagService = FakeFeatureFlagService(), - indicatorService = DefaultIndicatorService( - sessionVerificationService = sessionVerificationService, - encryptionService = FakeEncryptionService(), - ), - directLogoutPresenter = object : DirectLogoutPresenter { - @Composable - override fun present() = aDirectLogoutState - }, - ) + val presenter = createPresenter(matrixClient = matrixClient) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -104,4 +89,60 @@ class PreferencesRootPresenterTest { assertThat(loadedState.snackbarMessage).isNull() } } + + @Test + fun `present - developer settings is hidden by default in release builds`() = runTest { + val presenter = createPresenter( + showDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.RELEASE)) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val loadedState = awaitItem() + assertThat(loadedState.showDeveloperSettings).isFalse() + } + } + + @Test + fun `present - developer settings can be enabled in release builds`() = runTest { + val presenter = createPresenter( + showDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.RELEASE)) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val loadedState = awaitItem() + + repeat(times = ShowDeveloperSettingsProvider.DEVELOPER_SETTINGS_COUNTER) { + assertThat(loadedState.showDeveloperSettings).isFalse() + loadedState.eventSink(PreferencesRootEvents.OnVersionInfoClick) + } + + assertThat(awaitItem().showDeveloperSettings).isTrue() + } + } + + private fun createPresenter( + matrixClient: FakeMatrixClient = FakeMatrixClient(), + sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), + showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)), + ) = PreferencesRootPresenter( + matrixClient = matrixClient, + sessionVerificationService = sessionVerificationService, + analyticsService = FakeAnalyticsService(), + versionFormatter = FakeVersionFormatter(), + snackbarDispatcher = SnackbarDispatcher(), + featureFlagService = FakeFeatureFlagService(), + indicatorService = DefaultIndicatorService( + sessionVerificationService = sessionVerificationService, + encryptionService = FakeEncryptionService(), + ), + directLogoutPresenter = object : DirectLogoutPresenter { + @Composable + override fun present() = aDirectLogoutState + }, + showDeveloperSettingsProvider = showDeveloperSettingsProvider, + ) }