Merge pull request #1642 from vector-im/feature/fga/pin_settings

PIN : settings and branch flow
This commit is contained in:
ganfra 2023-10-26 16:40:38 +02:00 committed by GitHub
commit 35d32c0500
150 changed files with 1858 additions and 461 deletions

View file

@ -201,6 +201,7 @@ dependencies {
implementation(projects.features.call) implementation(projects.features.call)
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
implementation(projects.appnav) implementation(projects.appnav)
implementation(projects.appconfig)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
implementation(libs.appyx.core) implementation(libs.appyx.core)

View file

@ -16,9 +16,20 @@
plugins { plugins {
id("java-library") id("java-library")
alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
} }
java { java {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.di)
}

View file

@ -16,20 +16,51 @@
package io.element.android.appconfig package io.element.android.appconfig
object LockScreenConfig { import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.AppScope
/**
* Configuration for the lock screen feature.
*/
data class LockScreenConfig(
/** /**
* Whether the PIN is mandatory or not. * Whether the PIN is mandatory or not.
*/ */
const val IS_PIN_MANDATORY: Boolean = false val isPinMandatory: Boolean,
/** /**
* Some PINs are blacklisted. * Some PINs are blacklisted.
*/ */
val PIN_BLACKLIST = setOf("0000", "1234") val pinBlacklist: Set<String>,
/** /**
* The size of the PIN. * The size of the PIN.
*/ */
const val PIN_SIZE = 4 val pinSize: Int,
/**
* Number of attempts before the user is logged out.
*/
val maxPinCodeAttemptsBeforeLogout: Int,
/**
* Time period before locking the app once backgrounded.
*/
val gracePeriodInMillis: Long
)
@ContributesTo(AppScope::class)
@Module
object LockScreenConfigModule {
@Provides
fun providesLockScreenConfig(): LockScreenConfig = LockScreenConfig(
isPinMandatory = false,
pinBlacklist = setOf("0000", "1234"),
pinSize = 4,
maxPinCodeAttemptsBeforeLogout = 3,
gracePeriodInMillis = 90_000L
)
} }

View file

@ -48,11 +48,11 @@ import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.invitelist.api.InviteListEntryPoint import io.element.android.features.invitelist.api.InviteListEntryPoint
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenState
import io.element.android.features.lockscreen.api.LockScreenStateService
import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint import io.element.android.features.verifysession.api.VerifySessionEntryPoint
@ -94,7 +94,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val notificationDrawerManager: NotificationDrawerManager, private val notificationDrawerManager: NotificationDrawerManager,
private val ftueState: FtueState, private val ftueState: FtueState,
private val lockScreenEntryPoint: LockScreenEntryPoint, private val lockScreenEntryPoint: LockScreenEntryPoint,
private val lockScreenStateService: LockScreenStateService, private val lockScreenStateService: LockScreenService,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>( ) : BackstackNode<LoggedInFlowNode.NavTarget>(
@ -134,16 +134,6 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.Ftue) backstack.push(NavTarget.Ftue)
} }
}, },
onResume = {
coroutineScope.launch {
lockScreenStateService.entersForeground()
}
},
onPause = {
coroutineScope.launch {
lockScreenStateService.entersBackground()
}
},
onStop = { onStop = {
coroutineScope.launch { coroutineScope.launch {
//Counterpart startSync is done in observeSyncStateAndNetworkStatus method. //Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
@ -220,7 +210,9 @@ class LoggedInFlowNode @AssistedInject constructor(
createNode<LoggedInNode>(buildContext) createNode<LoggedInNode>(buildContext)
} }
NavTarget.LockPermanent -> { NavTarget.LockPermanent -> {
lockScreenEntryPoint.createNode(this, buildContext) lockScreenEntryPoint.nodeBuilder(this, buildContext)
.target(LockScreenEntryPoint.Target.Unlock)
.build()
} }
NavTarget.RoomList -> { NavTarget.RoomList -> {
val callback = object : RoomListEntryPoint.Callback { val callback = object : RoomListEntryPoint.Callback {
@ -357,9 +349,9 @@ class LoggedInFlowNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
Box(modifier = modifier) { Box(modifier = modifier) {
val lockScreenState by lockScreenStateService.state.collectAsState() val lockScreenState by lockScreenStateService.lockState.collectAsState()
when (lockScreenState) { when (lockScreenState) {
LockScreenState.Unlocked -> { LockScreenLockState.Unlocked -> {
Children( Children(
navModel = backstack, navModel = backstack,
modifier = Modifier, modifier = Modifier,
@ -371,7 +363,7 @@ class LoggedInFlowNode @AssistedInject constructor(
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
} }
} }
LockScreenState.Locked -> { LockScreenLockState.Locked -> {
MoveActivityToBackgroundBackHandler() MoveActivityToBackgroundBackHandler()
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockPermanent) PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockPermanent)
} }

View file

@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.features.lockscreen.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop) implementation(projects.libraries.permissions.noop)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
@ -57,6 +58,7 @@ dependencies {
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.permissions.impl) testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -39,6 +39,7 @@ import io.element.android.features.ftue.impl.notifications.NotificationsOptInNod
import io.element.android.features.ftue.impl.state.DefaultFtueState import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.WelcomeNode import io.element.android.features.ftue.impl.welcome.WelcomeNode
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -60,11 +61,12 @@ class FtueFlowNode @AssistedInject constructor(
private val ftueState: DefaultFtueState, private val ftueState: DefaultFtueState,
private val analyticsEntryPoint: AnalyticsEntryPoint, private val analyticsEntryPoint: AnalyticsEntryPoint,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val lockScreenEntryPoint: LockScreenEntryPoint,
) : BackstackNode<FtueFlowNode.NavTarget>( ) : BackstackNode<FtueFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Placeholder, initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
backPressHandler = NoOpBackstackHandlerStrategy<NavTarget>(), backPressHandler = NoOpBackstackHandlerStrategy(),
), ),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
@ -85,6 +87,9 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object AnalyticsOptIn : NavTarget data object AnalyticsOptIn : NavTarget
@Parcelize
data object LockScreenSetup : NavTarget
} }
private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull() private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull()
@ -139,6 +144,17 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.AnalyticsOptIn -> { NavTarget.AnalyticsOptIn -> {
analyticsEntryPoint.createNode(this, buildContext) analyticsEntryPoint.createNode(this, buildContext)
} }
NavTarget.LockScreenSetup -> {
val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupCompleted() {
lifecycleScope.launch { moveToNextStep() }
}
}
lockScreenEntryPoint.nodeBuilder(this, buildContext)
.callback(callback)
.target(LockScreenEntryPoint.Target.Setup)
.build()
}
} }
} }
@ -156,6 +172,9 @@ class FtueFlowNode @AssistedInject constructor(
FtueStep.AnalyticsOptIn -> { FtueStep.AnalyticsOptIn -> {
backstack.replace(NavTarget.AnalyticsOptIn) backstack.replace(NavTarget.AnalyticsOptIn)
} }
FtueStep.LockscreenSetup -> {
backstack.newRoot(NavTarget.LockScreenSetup)
}
null -> callback?.onFtueFlowFinished() null -> callback?.onFtueFlowFinished()
} }
} }

View file

@ -23,6 +23,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.ftue.impl.migration.MigrationScreenStore import io.element.android.features.ftue.impl.migration.MigrationScreenStore
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.permissions.api.PermissionStateProvider import io.element.android.libraries.permissions.api.PermissionStateProvider
@ -44,6 +45,7 @@ class DefaultFtueState @Inject constructor(
private val welcomeScreenState: WelcomeScreenState, private val welcomeScreenState: WelcomeScreenState,
private val migrationScreenStore: MigrationScreenStore, private val migrationScreenStore: MigrationScreenStore,
private val permissionStateProvider: PermissionStateProvider, private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
) : FtueState { ) : FtueState {
@ -72,10 +74,13 @@ class DefaultFtueState @Inject constructor(
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep( FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
FtueStep.WelcomeScreen FtueStep.WelcomeScreen
) )
FtueStep.WelcomeScreen -> if (shouldAskNotificationPermissions()) FtueStep.NotificationsOptIn else getNextStep( FtueStep.WelcomeScreen -> if (shouldAskNotificationPermissions()) FtueStep.NotificationsOptIn else getNextStep(
FtueStep.NotificationsOptIn FtueStep.NotificationsOptIn
) )
FtueStep.NotificationsOptIn -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep( FtueStep.NotificationsOptIn -> if (shouldDisplayLockscreenSetup()) FtueStep.LockscreenSetup else getNextStep(
FtueStep.LockscreenSetup
)
FtueStep.LockscreenSetup -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
FtueStep.AnalyticsOptIn FtueStep.AnalyticsOptIn
) )
FtueStep.AnalyticsOptIn -> null FtueStep.AnalyticsOptIn -> null
@ -83,11 +88,12 @@ class DefaultFtueState @Inject constructor(
private fun isAnyStepIncomplete(): Boolean { private fun isAnyStepIncomplete(): Boolean {
return listOf( return listOf(
shouldDisplayMigrationScreen(), { shouldDisplayMigrationScreen() },
shouldDisplayWelcomeScreen(), { shouldDisplayWelcomeScreen() },
shouldAskNotificationPermissions(), { shouldAskNotificationPermissions() },
needsAnalyticsOptIn() { needsAnalyticsOptIn() },
).any { it } { shouldDisplayLockscreenSetup() },
).any { it() }
} }
private fun shouldDisplayMigrationScreen(): Boolean { private fun shouldDisplayMigrationScreen(): Boolean {
@ -112,6 +118,12 @@ class DefaultFtueState @Inject constructor(
} else false } else false
} }
private fun shouldDisplayLockscreenSetup(): Boolean {
return runBlocking {
lockScreenService.isSetupRequired()
}
}
fun setWelcomeScreenShown() { fun setWelcomeScreenShown() {
welcomeScreenState.setWelcomeScreenShown() welcomeScreenState.setWelcomeScreenShown()
updateState() updateState()
@ -128,4 +140,5 @@ sealed interface FtueStep {
data object WelcomeScreen : FtueStep data object WelcomeScreen : FtueStep
data object NotificationsOptIn : FtueStep data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep data object AnalyticsOptIn : FtueStep
data object LockscreenSetup : FtueStep
} }

View file

@ -23,6 +23,8 @@ import io.element.android.features.ftue.impl.migration.MigrationScreenStore
import io.element.android.features.ftue.impl.state.DefaultFtueState import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -186,6 +188,7 @@ class DefaultFtueStateTests {
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(), migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false), permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
lockScreenService: LockScreenService = FakeLockScreenService(),
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, // First version where notification permission is required sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, // First version where notification permission is required
) = DefaultFtueState( ) = DefaultFtueState(
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion), sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
@ -194,6 +197,7 @@ class DefaultFtueStateTests {
welcomeScreenState = welcomeState, welcomeScreenState = welcomeState,
migrationScreenStore = migrationScreenStore, migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
matrixClient = matrixClient, matrixClient = matrixClient,
) )
} }

View file

@ -16,6 +16,28 @@
package io.element.android.features.lockscreen.api package io.element.android.features.lockscreen.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
interface LockScreenEntryPoint : SimpleFeatureEntryPoint interface LockScreenEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun target(target: Target): NodeBuilder
fun build(): Node
}
interface Callback: Plugin {
fun onSetupCompleted()
}
enum class Target {
Settings,
Setup,
Unlock
}
}

View file

@ -16,7 +16,7 @@
package io.element.android.features.lockscreen.api package io.element.android.features.lockscreen.api
sealed interface LockScreenState { sealed interface LockScreenLockState {
data object Unlocked : LockScreenState data object Unlocked : LockScreenLockState
data object Locked : LockScreenState data object Locked : LockScreenLockState
} }

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 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.lockscreen.api
import kotlinx.coroutines.flow.StateFlow
interface LockScreenService {
/**
* The current lock state of the app.
*/
val lockState: StateFlow<LockScreenLockState>
/**
* Check if setting up the lock screen is required.
* @return true if the lock screen is mandatory and not setup yet, false otherwise.
*/
suspend fun isSetupRequired(): Boolean
}

View file

@ -43,6 +43,9 @@ dependencies {
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api) implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.services.appnavstate.api)
implementation(libs.androidx.datastore.preferences)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
@ -54,4 +57,6 @@ dependencies {
testImplementation(projects.libraries.cryptography.test) testImplementation(projects.libraries.cryptography.test)
testImplementation(projects.libraries.cryptography.impl) testImplementation(projects.libraries.cryptography.impl)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
implementation(projects.libraries.sessionStorage.test)
implementation(projects.services.appnavstate.test)
} }

View file

@ -27,7 +27,34 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultLockScreenEntryPoint @Inject constructor() : LockScreenEntryPoint { class DefaultLockScreenEntryPoint @Inject constructor() : LockScreenEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node { override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LockScreenEntryPoint.NodeBuilder {
return parentNode.createNode<LockScreenFlowNode>(buildContext)
var innerTarget: LockScreenEntryPoint.Target = LockScreenEntryPoint.Target.Unlock
val callbacks = mutableListOf<LockScreenEntryPoint.Callback>()
return object : LockScreenEntryPoint.NodeBuilder {
override fun callback(callback: LockScreenEntryPoint.Callback): LockScreenEntryPoint.NodeBuilder {
callbacks += callback
return this
}
override fun target(target: LockScreenEntryPoint.Target): LockScreenEntryPoint.NodeBuilder {
innerTarget = target
return this
}
override fun build(): Node {
val inputs = LockScreenFlowNode.Inputs(
when (innerTarget) {
LockScreenEntryPoint.Target.Unlock -> LockScreenFlowNode.NavTarget.Unlock
LockScreenEntryPoint.Target.Setup -> LockScreenFlowNode.NavTarget.Setup
LockScreenEntryPoint.Target.Settings -> LockScreenFlowNode.NavTarget.Settings
}
)
val plugins = listOf(inputs) + callbacks
return parentNode.createNode<LockScreenFlowNode>(buildContext, plugins)
}
}
} }
} }

View file

@ -0,0 +1,114 @@
/*
* Copyright (c) 2023 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.lockscreen.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultLockScreenService @Inject constructor(
private val lockScreenConfig: LockScreenConfig,
private val featureFlagService: FeatureFlagService,
private val pinCodeManager: PinCodeManager,
private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver,
private val appForegroundStateService: AppForegroundStateService,
) : LockScreenService {
private val _lockScreenState = MutableStateFlow<LockScreenLockState>(LockScreenLockState.Unlocked)
override val lockState: StateFlow<LockScreenLockState> = _lockScreenState
private var lockJob: Job? = null
init {
pinCodeManager.addCallback(object : DefaultPinCodeManagerCallback() {
override fun onPinCodeVerified() {
_lockScreenState.value = LockScreenLockState.Unlocked
}
override fun onPinCodeRemoved() {
_lockScreenState.value = LockScreenLockState.Unlocked
}
})
coroutineScope.lockIfNeeded()
observeAppForegroundState()
observeSessionsState()
}
/**
* Makes sure to delete the pin code when the session is deleted.
*/
private fun observeSessionsState() {
sessionObserver.addListener(object : SessionListener {
override suspend fun onSessionCreated(userId: String) = Unit
override suspend fun onSessionDeleted(userId: String) {
//TODO handle multi session at some point
pinCodeManager.deletePinCode()
}
})
}
/**
* Makes sure to lock the app if it goes in background for a certain amount of time.
*/
private fun observeAppForegroundState() {
coroutineScope.launch {
appForegroundStateService.start()
appForegroundStateService.isInForeground.collect { isInForeground ->
if (isInForeground) {
lockJob?.cancel()
} else {
lockJob = lockIfNeeded(delayInMillis = lockScreenConfig.gracePeriodInMillis)
}
}
}
}
override suspend fun isSetupRequired(): Boolean {
return lockScreenConfig.isPinMandatory
&& featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)
&& !pinCodeManager.isPinCodeAvailable()
}
private fun CoroutineScope.lockIfNeeded(delayInMillis: Long = 0L) = launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) && pinCodeManager.isPinCodeAvailable()) {
delay(delayInMillis)
_lockScreenState.value = LockScreenLockState.Locked
}
}
}

View file

@ -20,40 +20,75 @@ import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsFlowNode
import io.element.android.features.lockscreen.impl.setup.SetupPinNode import io.element.android.features.lockscreen.impl.setup.SetupPinNode
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class) @ContributesNode(SessionScope::class)
class LockScreenFlowNode @AssistedInject constructor( class LockScreenFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager,
) : BackstackNode<LockScreenFlowNode.NavTarget>( ) : BackstackNode<LockScreenFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Unlock, initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialNavTarget,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
) { ) {
data class Inputs(
val initialNavTarget: NavTarget = NavTarget.Unlock,
) : NodeInputs
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data object Unlock : NavTarget data object Unlock : NavTarget
@Parcelize @Parcelize
data object Setup : NavTarget data object Setup : NavTarget
@Parcelize
data object Settings : NavTarget
}
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
plugins<LockScreenEntryPoint.Callback>().forEach {
it.onSetupCompleted()
}
}
}
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
pinCodeManager.addCallback(pinCodeManagerCallback)
},
onDestroy = {
pinCodeManager.removeCallback(pinCodeManagerCallback)
}
)
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -64,6 +99,9 @@ class LockScreenFlowNode @AssistedInject constructor(
NavTarget.Setup -> { NavTarget.Setup -> {
createNode<SetupPinNode>(buildContext) createNode<SetupPinNode>(buildContext)
} }
NavTarget.Settings -> {
createNode<LockScreenSettingsFlowNode>(buildContext)
}
} }
} }

View file

@ -20,7 +20,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
@ -30,7 +33,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.lockscreen.impl.pin.model.PinDigit import io.element.android.features.lockscreen.impl.pin.model.PinDigit
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
@ -42,34 +44,37 @@ import io.element.android.libraries.theme.ElementTheme
@Composable @Composable
fun PinEntryTextField( fun PinEntryTextField(
pinEntry: PinEntry, pinEntry: PinEntry,
isSecured: Boolean,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BasicTextField( BasicTextField(
modifier = modifier, modifier = modifier,
value = TextFieldValue(pinEntry.toText()), value = pinEntry.toText(),
onValueChange = { onValueChange = {
onValueChange(it.text) onValueChange(it)
}, },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
decorationBox = { decorationBox = {
PinEntryRow(pinEntry = pinEntry) PinEntryRow(pinEntry = pinEntry, isSecured = isSecured)
} }
) )
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun PinEntryRow( private fun PinEntryRow(
pinEntry: PinEntry, pinEntry: PinEntry,
isSecured: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( FlowRow(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally), horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
for (digit in pinEntry.digits) { for (digit in pinEntry.digits) {
PinDigitView(digit = digit) PinDigitView(digit = digit, isSecured = isSecured)
} }
} }
} }
@ -77,6 +82,7 @@ private fun PinEntryRow(
@Composable @Composable
private fun PinDigitView( private fun PinDigitView(
digit: PinDigit, digit: PinDigit,
isSecured: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val shape = RoundedCornerShape(8.dp) val shape = RoundedCornerShape(8.dp)
@ -96,8 +102,13 @@ private fun PinDigitView(
) { ) {
if (digit is PinDigit.Filled) { if (digit is PinDigit.Filled) {
val text = if (isSecured) {
""
} else {
digit.value.toString()
}
Text( Text(
text = digit.toText(), text = text,
style = ElementTheme.typography.fontHeadingMdBold style = ElementTheme.typography.fontHeadingMdBold
) )
} }
@ -109,9 +120,19 @@ private fun PinDigitView(
@Composable @Composable
internal fun PinEntryTextFieldPreview() { internal fun PinEntryTextFieldPreview() {
ElementPreview { ElementPreview {
PinEntryTextField( val pinEntry = PinEntry.createEmpty(4).fillWith("12")
pinEntry = PinEntry.createEmpty(4).fillWith("12"), Column {
onValueChange = {}, PinEntryTextField(
) pinEntry = pinEntry,
isSecured = true,
onValueChange = {},
)
Spacer(modifier = Modifier.size(16.dp))
PinEntryTextField(
pinEntry = pinEntry,
isSecured = false,
onValueChange = {},
)
}
} }
} }

View file

@ -22,25 +22,46 @@ import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.EncryptionResult import io.element.android.libraries.cryptography.api.EncryptionResult
import io.element.android.libraries.cryptography.api.SecretKeyProvider import io.element.android.libraries.cryptography.api.SecretKeyProvider
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject import javax.inject.Inject
private const val SECRET_KEY_ALIAS = "SECRET_KEY_ALIAS_PIN_CODE" private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultPinCodeManager @Inject constructor( class DefaultPinCodeManager @Inject constructor(
private val secretKeyProvider: SecretKeyProvider, private val secretKeyProvider: SecretKeyProvider,
private val encryptionDecryptionService: EncryptionDecryptionService, private val encryptionDecryptionService: EncryptionDecryptionService,
private val pinCodeStore: PinCodeStore, private val pinCodeStore: PinCodeStore,
) : PinCodeManager { ) : PinCodeManager {
private val callbacks = CopyOnWriteArrayList<PinCodeManager.Callback>()
override fun addCallback(callback: PinCodeManager.Callback) {
callbacks.add(callback)
}
override fun removeCallback(callback: PinCodeManager.Callback) {
callbacks.remove(callback)
}
override suspend fun isPinCodeAvailable(): Boolean { override suspend fun isPinCodeAvailable(): Boolean {
return pinCodeStore.hasPinCode() return pinCodeStore.hasPinCode()
} }
override suspend fun getPinCodeSize(): Int {
val encryptedPinCode = pinCodeStore.getEncryptedCode() ?: return 0
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
return decryptedPinCode.size
}
override suspend fun createPinCode(pinCode: String) { override suspend fun createPinCode(pinCode: String) {
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS) val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
val encryptedPinCode = encryptionDecryptionService.encrypt(secretKey, pinCode.toByteArray()).toBase64() val encryptedPinCode = encryptionDecryptionService.encrypt(secretKey, pinCode.toByteArray()).toBase64()
pinCodeStore.saveEncryptedPinCode(encryptedPinCode) pinCodeStore.saveEncryptedPinCode(encryptedPinCode)
callbacks.forEach { it.onPinCodeCreated() }
} }
override suspend fun verifyPinCode(pinCode: String): Boolean { override suspend fun verifyPinCode(pinCode: String): Boolean {
@ -48,7 +69,17 @@ class DefaultPinCodeManager @Inject constructor(
return try { return try {
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS) val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode)) val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
decryptedPinCode.contentEquals(pinCode.toByteArray()) val pinCodeToCheck = pinCode.toByteArray()
decryptedPinCode.contentEquals(pinCodeToCheck).also { isPinCodeCorrect ->
if (isPinCodeCorrect) {
pinCodeStore.resetCounter()
callbacks.forEach { callback ->
callback.onPinCodeVerified()
}
} else {
pinCodeStore.onWrongPin()
}
}
} catch (failure: Throwable) { } catch (failure: Throwable) {
false false
} }
@ -56,17 +87,11 @@ class DefaultPinCodeManager @Inject constructor(
override suspend fun deletePinCode() { override suspend fun deletePinCode() {
pinCodeStore.deleteEncryptedPinCode() pinCodeStore.deleteEncryptedPinCode()
pinCodeStore.resetCounter()
callbacks.forEach { it.onPinCodeRemoved() }
} }
override suspend fun getRemainingPinCodeAttemptsNumber(): Int { override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
return pinCodeStore.getRemainingPinCodeAttemptsNumber() return pinCodeStore.getRemainingPinCodeAttemptsNumber()
} }
override suspend fun onWrongPin(): Int {
return pinCodeStore.onWrongPin()
}
override suspend fun resetCounter() {
pinCodeStore.resetCounter()
}
} }

View file

@ -14,14 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.api package io.element.android.features.lockscreen.impl.pin
import kotlinx.coroutines.flow.StateFlow open class DefaultPinCodeManagerCallback : PinCodeManager.Callback {
override fun onPinCodeVerified() = Unit
interface LockScreenStateService { override fun onPinCodeCreated() = Unit
val state: StateFlow<LockScreenState>
suspend fun entersForeground() override fun onPinCodeRemoved() = Unit
suspend fun entersBackground()
suspend fun unlock()
} }

View file

@ -21,11 +21,47 @@ package io.element.android.features.lockscreen.impl.pin
* Implementation should take care of encrypting the pin code and storing it. * Implementation should take care of encrypting the pin code and storing it.
*/ */
interface PinCodeManager { interface PinCodeManager {
/**
* Callbacks for pin code management events.
*/
interface Callback {
/**
* Called when the pin code is verified.
*/
fun onPinCodeVerified()
/**
* Called when the pin code is created.
*/
fun onPinCodeCreated()
/**
* Called when the pin code is removed.
*/
fun onPinCodeRemoved()
}
/**
* Register a callback to be notified of pin code management events.
*/
fun addCallback(callback: Callback)
/**
* Unregister callback to be notified of pin code management events.
*/
fun removeCallback(callback: Callback)
/** /**
* @return true if a pin code is available. * @return true if a pin code is available.
*/ */
suspend fun isPinCodeAvailable(): Boolean suspend fun isPinCodeAvailable(): Boolean
/**
* @return the size of the saved pin code.
*/
suspend fun getPinCodeSize(): Int
/** /**
* Creates a new encrypted pin code. * Creates a new encrypted pin code.
* @param pinCode the clear pin code to create * @param pinCode the clear pin code to create
@ -46,16 +82,4 @@ interface PinCodeManager {
* @return the number of remaining attempts before the pin code is blocked. * @return the number of remaining attempts before the pin code is blocked.
*/ */
suspend fun getRemainingPinCodeAttemptsNumber(): Int suspend fun getRemainingPinCodeAttemptsNumber(): Int
/**
* Should be called when the pin code is incorrect.
* Will decrement the remaining attempts number.
* @return the number of remaining attempts before the pin code is blocked.
*/
suspend fun onWrongPin(): Int
/**
* Resets the counter of attempts for PIN code.
*/
suspend fun resetCounter()
} }

View file

@ -41,9 +41,9 @@ data class PinEntry(
* @return the new PinEntry * @return the new PinEntry
*/ */
fun fillWith(text: String): PinEntry { fun fillWith(text: String): PinEntry {
val newDigits = digits.toMutableList() val newDigits = MutableList<PinDigit>(size) { PinDigit.Empty }
text.forEachIndexed { index, char -> text.forEachIndexed { index, char ->
if (index < size) { if (index < size && char.isDigit()) {
newDigits[index] = PinDigit.Filled(char) newDigits[index] = PinDigit.Filled(char)
} }
} }

View file

@ -18,10 +18,6 @@ package io.element.android.features.lockscreen.impl.pin.storage
interface PinCodeStore : EncryptedPinCodeStorage { interface PinCodeStore : EncryptedPinCodeStorage {
interface Listener {
fun onPinSetUpChange(isConfigured: Boolean)
}
/** /**
* Returns the remaining PIN code attempts. When this reaches 0 the PIN code access won't be available for some time. * Returns the remaining PIN code attempts. When this reaches 0 the PIN code access won't be available for some time.
*/ */
@ -29,24 +25,13 @@ interface PinCodeStore : EncryptedPinCodeStorage {
/** /**
* Should decrement the number of remaining PIN code attempts. * Should decrement the number of remaining PIN code attempts.
* @return The remaining attempts.
*/ */
suspend fun onWrongPin(): Int suspend fun onWrongPin()
/** /**
* Resets the counter of attempts for PIN code and biometric access. * Resets the counter of attempts for PIN code and biometric access.
*/ */
suspend fun resetCounter() suspend fun resetCounter()
/**
* Adds a listener to be notified when the PIN code us created or removed.
*/
fun addListener(listener: Listener)
/**
* Removes a listener to be notified when the PIN code us created or removed.
*/
fun removeListener(listener: Listener)
} }

View file

@ -0,0 +1,92 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.pin.storage
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.LockScreenConfig
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "pin_code_store")
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class PreferencesPinCodeStore @Inject constructor(
@ApplicationContext private val context: Context,
private val lockScreenConfig: LockScreenConfig,
) : PinCodeStore {
private val pinCodeKey = stringPreferencesKey("encoded_pin_code")
private val remainingAttemptsKey = intPreferencesKey("remaining_pin_code_attempts")
override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
return context.dataStore.data.map { preferences ->
preferences.getRemainingPinCodeAttemptsNumber()
}.first()
}
override suspend fun onWrongPin() {
context.dataStore.edit { preferences ->
val current = preferences.getRemainingPinCodeAttemptsNumber()
val remaining = (current - 1).coerceAtLeast(0)
preferences[remainingAttemptsKey] = remaining
}
}
override suspend fun resetCounter() {
context.dataStore.edit { preferences ->
preferences[remainingAttemptsKey] = lockScreenConfig.maxPinCodeAttemptsBeforeLogout
}
}
override suspend fun getEncryptedCode(): String? {
return context.dataStore.data.map { preferences ->
preferences[pinCodeKey]
}.first()
}
override suspend fun saveEncryptedPinCode(pinCode: String) {
context.dataStore.edit { preferences ->
preferences[pinCodeKey] = pinCode
}
}
override suspend fun deleteEncryptedPinCode() {
context.dataStore.edit { preferences ->
preferences.remove(pinCodeKey)
}
}
override suspend fun hasPinCode(): Boolean {
return context.dataStore.data.map { preferences ->
preferences[pinCodeKey] != null
}.first()
}
private fun Preferences.getRemainingPinCodeAttemptsNumber() = this[remainingAttemptsKey] ?: lockScreenConfig.maxPinCodeAttemptsBeforeLogout
}

View file

@ -1,104 +0,0 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.pin.storage
import android.content.SharedPreferences
import androidx.core.content.edit
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject
private const val ENCODED_PIN_CODE_KEY = "ENCODED_PIN_CODE_KEY"
private const val REMAINING_PIN_CODE_ATTEMPTS_KEY = "REMAINING_PIN_CODE_ATTEMPTS_KEY"
private const val MAX_PIN_CODE_ATTEMPTS_NUMBER_BEFORE_LOGOUT = 3
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class SharedPreferencesPinCodeStore @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val sharedPreferences: SharedPreferences,
) : PinCodeStore {
private val listeners = CopyOnWriteArrayList<PinCodeStore.Listener>()
private val mutex = Mutex()
override suspend fun getEncryptedCode(): String? = withContext(dispatchers.io) {
sharedPreferences.getString(ENCODED_PIN_CODE_KEY, null)
}
override suspend fun saveEncryptedPinCode(pinCode: String) = withContext(dispatchers.io) {
sharedPreferences.edit {
putString(ENCODED_PIN_CODE_KEY, pinCode)
}
withContext(dispatchers.main) {
listeners.forEach { it.onPinSetUpChange(isConfigured = true) }
}
}
override suspend fun deleteEncryptedPinCode() = withContext(dispatchers.io) {
// Also reset the counters
resetCounter()
sharedPreferences.edit {
remove(ENCODED_PIN_CODE_KEY)
}
withContext(dispatchers.main) {
listeners.forEach { it.onPinSetUpChange(isConfigured = false) }
}
}
override suspend fun hasPinCode(): Boolean = withContext(dispatchers.io) {
sharedPreferences.contains(ENCODED_PIN_CODE_KEY)
}
override suspend fun getRemainingPinCodeAttemptsNumber(): Int = withContext(dispatchers.io) {
mutex.withLock {
sharedPreferences.getInt(REMAINING_PIN_CODE_ATTEMPTS_KEY, MAX_PIN_CODE_ATTEMPTS_NUMBER_BEFORE_LOGOUT)
}
}
override suspend fun onWrongPin(): Int = withContext(dispatchers.io) {
mutex.withLock {
val remaining = getRemainingPinCodeAttemptsNumber() - 1
sharedPreferences.edit {
putInt(REMAINING_PIN_CODE_ATTEMPTS_KEY, remaining)
}
remaining
}
}
override suspend fun resetCounter() = withContext(dispatchers.io) {
mutex.withLock {
sharedPreferences.edit {
remove(REMAINING_PIN_CODE_ATTEMPTS_KEY)
}
}
}
override fun addListener(listener: PinCodeStore.Listener) {
listeners.add(listener)
}
override fun removeListener(listener: PinCodeStore.Listener) {
listeners.remove(listener)
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.settings
sealed interface LockScreenSettingsEvents {
data object OnRemovePin : LockScreenSettingsEvents
data object ConfirmRemovePin : LockScreenSettingsEvents
data object CancelRemovePin : LockScreenSettingsEvents
data object ToggleBiometric : LockScreenSettingsEvents
}

View file

@ -0,0 +1,134 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.settings
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.setup.SetupPinNode
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class LockScreenSettingsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager,
) : BackstackNode<LockScreenSettingsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Unknown,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Unknown : NavTarget
@Parcelize
data object Unlock : NavTarget
@Parcelize
data object Setup : NavTarget
@Parcelize
data object Settings : NavTarget
}
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeVerified() {
backstack.newRoot(NavTarget.Settings)
}
override fun onPinCodeCreated() {
backstack.newRoot(NavTarget.Settings)
}
override fun onPinCodeRemoved() {
navigateUp()
}
}
init {
lifecycleScope.launch {
if (pinCodeManager.isPinCodeAvailable()) {
backstack.newRoot(NavTarget.Unlock)
} else {
backstack.newRoot(NavTarget.Setup)
}
}
lifecycle.subscribe(
onCreate = {
pinCodeManager.addCallback(pinCodeManagerCallback)
},
onDestroy = {
pinCodeManager.removeCallback(pinCodeManagerCallback)
}
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Unlock -> {
createNode<PinUnlockNode>(buildContext)
}
NavTarget.Setup -> {
createNode<SetupPinNode>(buildContext)
}
NavTarget.Settings -> {
val callback = object : LockScreenSettingsNode.Callback {
override fun onChangePinClicked() {
backstack.push(NavTarget.Setup)
}
}
createNode<LockScreenSettingsNode>(buildContext, plugins = listOf(callback))
}
NavTarget.Unknown -> node(buildContext) { }
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class LockScreenSettingsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: LockScreenSettingsPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onChangePinClicked()
}
private fun onChangePinClicked() {
plugins<Callback>().forEach { it.onChangePinClicked() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LockScreenSettingsView(
state = state,
onBackPressed = this::navigateUp,
onChangePinClicked = this::onChangePinClicked,
modifier = modifier,
)
}
}

View file

@ -0,0 +1,83 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class LockScreenSettingsPresenter @Inject constructor(
private val lockScreenConfig: LockScreenConfig,
private val pinCodeManager: PinCodeManager,
private val coroutineScope: CoroutineScope,
) : Presenter<LockScreenSettingsState> {
@Composable
override fun present(): LockScreenSettingsState {
var triggerComputation by remember {
mutableIntStateOf(0)
}
var showRemovePinOption by remember {
mutableStateOf(false)
}
var isBiometricEnabled by remember {
mutableStateOf(false)
}
var showRemovePinConfirmation by remember {
mutableStateOf(false)
}
LaunchedEffect(triggerComputation) {
showRemovePinOption = !lockScreenConfig.isPinMandatory && pinCodeManager.isPinCodeAvailable()
}
fun handleEvents(event: LockScreenSettingsEvents) {
when (event) {
LockScreenSettingsEvents.CancelRemovePin -> showRemovePinConfirmation = false
LockScreenSettingsEvents.ConfirmRemovePin -> {
coroutineScope.launch {
if (showRemovePinConfirmation) {
showRemovePinConfirmation = false
pinCodeManager.deletePinCode()
triggerComputation++
}
}
}
LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true
LockScreenSettingsEvents.ToggleBiometric -> {
//TODO branch biometric logic
}
}
}
return LockScreenSettingsState(
showRemovePinOption = showRemovePinOption,
isBiometricEnabled = isBiometricEnabled,
showRemovePinConfirmation = showRemovePinConfirmation,
eventSink = ::handleEvents
)
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.settings
data class LockScreenSettingsState(
val showRemovePinOption: Boolean,
val isBiometricEnabled: Boolean,
val showRemovePinConfirmation: Boolean,
val eventSink: (LockScreenSettingsEvents) -> Unit
)

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.settings
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class LockScreenSettingsStateProvider : PreviewParameterProvider<LockScreenSettingsState> {
override val values: Sequence<LockScreenSettingsState>
get() = sequenceOf(
aLockScreenSettingsState(),
aLockScreenSettingsState(isLockMandatory = true),
aLockScreenSettingsState(showRemovePinConfirmation = true),
)
}
fun aLockScreenSettingsState(
isLockMandatory: Boolean = false,
isBiometricEnabled: Boolean = false,
showRemovePinConfirmation: Boolean = false,
) = LockScreenSettingsState(
showRemovePinOption = isLockMandatory,
isBiometricEnabled = isBiometricEnabled,
showRemovePinConfirmation = showRemovePinConfirmation,
eventSink = {}
)

View file

@ -0,0 +1,90 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.lockscreen.impl.R
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.theme.ElementTheme
@Composable
fun LockScreenSettingsView(
state: LockScreenSettingsState,
onChangePinClicked: () -> Unit,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
PreferencePage(
title = stringResource(id = io.element.android.libraries.ui.strings.R.string.common_screen_lock),
onBackPressed = onBackPressed,
modifier = modifier
) {
PreferenceCategory(showDivider = false) {
PreferenceText(
title = stringResource(id = R.string.screen_app_lock_settings_change_pin),
onClick = onChangePinClicked
)
PreferenceDivider()
if (state.showRemovePinOption) {
PreferenceText(
title = stringResource(id = R.string.screen_app_lock_settings_remove_pin),
tintColor = ElementTheme.colors.textCriticalPrimary,
onClick = {
state.eventSink(LockScreenSettingsEvents.OnRemovePin)
}
)
}
PreferenceDivider()
PreferenceSwitch(title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock), isChecked = state.isBiometricEnabled)
}
}
if (state.showRemovePinConfirmation) {
ConfirmationDialog(
title = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_title),
content = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_message),
onSubmitClicked = {
state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin)
},
onDismiss = {
state.eventSink(LockScreenSettingsEvents.CancelRemovePin)
})
}
}
@PreviewsDayNight
@Composable
internal fun LockScreenSettingsViewPreview(
@PreviewParameter(LockScreenSettingsStateProvider::class) state: LockScreenSettingsState,
) {
ElementPreview {
LockScreenSettingsView(
state = state,
onChangePinClicked = {},
onBackPressed = {},
)
}
}

View file

@ -24,9 +24,9 @@ import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SessionScope
@ContributesNode(AppScope::class) @ContributesNode(SessionScope::class)
class SetupPinNode @AssistedInject constructor( class SetupPinNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
@ -38,7 +38,7 @@ class SetupPinNode @AssistedInject constructor(
val state = presenter.present() val state = presenter.present()
SetupPinView( SetupPinView(
state = state, state = state,
onBackClicked = { }, onBackClicked = this::navigateUp,
modifier = modifier modifier = modifier
) )
} }

View file

@ -17,30 +17,35 @@
package io.element.android.features.lockscreen.impl.setup package io.element.android.features.lockscreen.impl.setup
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.appconfig.LockScreenConfig import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.setup.validation.PinValidator import io.element.android.features.lockscreen.impl.setup.validation.PinValidator
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.coroutines.delay
import javax.inject.Inject import javax.inject.Inject
class SetupPinPresenter @Inject constructor( class SetupPinPresenter @Inject constructor(
private val lockScreenConfig: LockScreenConfig,
private val pinValidator: PinValidator, private val pinValidator: PinValidator,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val pinCodeManager: PinCodeManager,
) : Presenter<SetupPinState> { ) : Presenter<SetupPinState> {
@Composable @Composable
override fun present(): SetupPinState { override fun present(): SetupPinState {
var choosePinEntry by remember { var choosePinEntry by remember {
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE)) mutableStateOf(PinEntry.createEmpty(lockScreenConfig.pinSize))
} }
var confirmPinEntry by remember { var confirmPinEntry by remember {
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE)) mutableStateOf(PinEntry.createEmpty(lockScreenConfig.pinSize))
} }
var isConfirmationStep by remember { var isConfirmationStep by remember {
mutableStateOf(false) mutableStateOf(false)
@ -48,29 +53,38 @@ class SetupPinPresenter @Inject constructor(
var setupPinFailure by remember { var setupPinFailure by remember {
mutableStateOf<SetupPinFailure?>(null) mutableStateOf<SetupPinFailure?>(null)
} }
LaunchedEffect(choosePinEntry) {
if (choosePinEntry.isComplete()) {
when (val pinValidationResult = pinValidator.isPinValid(choosePinEntry)) {
is PinValidator.Result.Invalid -> {
setupPinFailure = pinValidationResult.failure
}
PinValidator.Result.Valid -> {
// Leave some time for the ui to refresh before showing confirmation
delay(150)
isConfirmationStep = true
}
}
}
}
LaunchedEffect(confirmPinEntry) {
if (confirmPinEntry.isComplete()) {
if (confirmPinEntry == choosePinEntry) {
pinCodeManager.createPinCode(confirmPinEntry.toText())
} else {
setupPinFailure = SetupPinFailure.PinsDontMatch
}
}
}
fun handleEvents(event: SetupPinEvents) { fun handleEvents(event: SetupPinEvents) {
when (event) { when (event) {
is SetupPinEvents.OnPinEntryChanged -> { is SetupPinEvents.OnPinEntryChanged -> {
if (isConfirmationStep) { if (isConfirmationStep) {
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText) confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
if (confirmPinEntry.isComplete()) {
if (confirmPinEntry == choosePinEntry) {
//TODO save in db and navigate to next screen
} else {
setupPinFailure = SetupPinFailure.PinsDontMatch
}
}
} else { } else {
choosePinEntry = choosePinEntry.fillWith(event.entryAsText) choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
if (choosePinEntry.isComplete()) {
when (val pinValidationResult = pinValidator.isPinValid(choosePinEntry)) {
is PinValidator.Result.Invalid -> {
setupPinFailure = pinValidationResult.failure
}
PinValidator.Result.Valid -> isConfirmationStep = true
}
}
} }
} }
SetupPinEvents.ClearFailure -> { SetupPinEvents.ClearFailure -> {

View file

@ -29,8 +29,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -105,12 +109,18 @@ private fun SetupPinContent(
state: SetupPinState, state: SetupPinState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
PinEntryTextField( PinEntryTextField(
state.activePinEntry, pinEntry = state.activePinEntry,
isSecured = true,
onValueChange = { onValueChange = {
state.eventSink(SetupPinEvents.OnPinEntryChanged(it)) state.eventSink(SetupPinEvents.OnPinEntryChanged(it))
}, },
modifier = modifier modifier = modifier
.focusRequester(focusRequester)
.padding(top = 36.dp) .padding(top = 36.dp)
.fillMaxWidth() .fillMaxWidth()
) )

View file

@ -20,10 +20,7 @@ import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import javax.inject.Inject import javax.inject.Inject
class PinValidator internal constructor(private val pinBlacklist: Set<String>) { class PinValidator @Inject constructor(private val lockScreenConfig: LockScreenConfig) {
@Inject
constructor() : this(LockScreenConfig.PIN_BLACKLIST)
sealed interface Result { sealed interface Result {
data object Valid : Result data object Valid : Result
@ -32,7 +29,7 @@ class PinValidator internal constructor(private val pinBlacklist: Set<String>) {
fun isPinValid(pinEntry: PinEntry): Result { fun isPinValid(pinEntry: PinEntry): Result {
val pinAsText = pinEntry.toText() val pinAsText = pinEntry.toText()
val isBlacklisted = pinBlacklist.any { it == pinAsText } val isBlacklisted = lockScreenConfig.pinBlacklist.any { it == pinAsText }
return if (isBlacklisted) { return if (isBlacklisted) {
Result.Invalid(SetupPinFailure.PinBlacklisted) Result.Invalid(SetupPinFailure.PinBlacklisted)
} else { } else {

View file

@ -1,64 +0,0 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.state
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.lockscreen.api.LockScreenState
import io.element.android.features.lockscreen.api.LockScreenStateService
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
//private const val GRACE_PERIOD_IN_MILLIS = 90 * 1000L
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultLockScreenStateService @Inject constructor(
private val featureFlagService: FeatureFlagService,
) : LockScreenStateService {
private val _lockScreenState = MutableStateFlow<LockScreenState>(LockScreenState.Unlocked)
override val state: StateFlow<LockScreenState> = _lockScreenState
private var lockJob: Job? = null
override suspend fun unlock() {
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)) {
_lockScreenState.value = LockScreenState.Unlocked
}
}
override suspend fun entersForeground() {
lockJob?.cancel()
}
override suspend fun entersBackground() = coroutineScope {
lockJob = launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)) {
//delay(GRACE_PERIOD_IN_MILLIS)
_lockScreenState.value = LockScreenState.Locked
}
}
}
}

View file

@ -22,4 +22,6 @@ sealed interface PinUnlockEvents {
data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents
data object OnForgetPin : PinUnlockEvents data object OnForgetPin : PinUnlockEvents
data object ClearSignOutPrompt : PinUnlockEvents data object ClearSignOutPrompt : PinUnlockEvents
data object SignOut : PinUnlockEvents
data object OnUseBiometric : PinUnlockEvents
} }

View file

@ -24,9 +24,9 @@ import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SessionScope
@ContributesNode(AppScope::class) @ContributesNode(SessionScope::class)
class PinUnlockNode @AssistedInject constructor( class PinUnlockNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,

View file

@ -17,35 +17,39 @@
package io.element.android.features.lockscreen.impl.unlock package io.element.android.features.lockscreen.impl.unlock
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.appconfig.LockScreenConfig import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.api.LockScreenStateService
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter 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.matrix.api.MatrixClient
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class PinUnlockPresenter @Inject constructor( class PinUnlockPresenter @Inject constructor(
private val pinStateService: LockScreenStateService, private val pinCodeManager: PinCodeManager,
private val matrixClient: MatrixClient,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
) : Presenter<PinUnlockState> { ) : Presenter<PinUnlockState> {
@Composable @Composable
override fun present(): PinUnlockState { override fun present(): PinUnlockState {
var pinEntry by remember { val pinEntryState = remember {
//TODO fetch size from db mutableStateOf<Async<PinEntry>>(Async.Uninitialized)
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE))
} }
var remainingAttempts by rememberSaveable { val pinEntry by pinEntryState
//TODO fetch from db var remainingAttempts by remember {
mutableIntStateOf(3) mutableStateOf<Async<Int>>(Async.Uninitialized)
} }
var showWrongPinTitle by rememberSaveable { var showWrongPinTitle by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
@ -53,18 +57,48 @@ class PinUnlockPresenter @Inject constructor(
var showSignOutPrompt by rememberSaveable { var showSignOutPrompt by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
} }
val signOutAction = remember {
mutableStateOf<Async<String?>>(Async.Uninitialized)
}
LaunchedEffect(Unit) {
suspend {
val pinCodeSize = pinCodeManager.getPinCodeSize()
PinEntry.createEmpty(pinCodeSize)
}.runCatchingUpdatingState(pinEntryState)
}
LaunchedEffect(pinEntry) {
if (pinEntry.isComplete()) {
val isVerified = pinCodeManager.verifyPinCode(pinEntry.toText())
if (!isVerified) {
pinEntryState.value = pinEntry.clear()
showWrongPinTitle = true
}
}
val remainingAttemptsNumber = pinCodeManager.getRemainingPinCodeAttemptsNumber()
remainingAttempts = Async.Success(remainingAttemptsNumber)
if (remainingAttemptsNumber == 0) {
showSignOutPrompt = true
}
}
fun handleEvents(event: PinUnlockEvents) { fun handleEvents(event: PinUnlockEvents) {
when (event) { when (event) {
is PinUnlockEvents.OnPinKeypadPressed -> { is PinUnlockEvents.OnPinKeypadPressed -> {
pinEntry = pinEntry.process(event.pinKeypadModel) pinEntryState.value = pinEntry.process(event.pinKeypadModel)
if (pinEntry.isComplete()) {
//TODO check pin with PinCodeManager
coroutineScope.launch { pinStateService.unlock() }
}
} }
PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true
PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false
PinUnlockEvents.SignOut -> {
if (showSignOutPrompt) {
showSignOutPrompt = false
coroutineScope.signOut(signOutAction)
}
}
PinUnlockEvents.OnUseBiometric -> {
//TODO
}
} }
} }
return PinUnlockState( return PinUnlockState(
@ -72,15 +106,43 @@ class PinUnlockPresenter @Inject constructor(
showWrongPinTitle = showWrongPinTitle, showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts, remainingAttempts = remainingAttempts,
showSignOutPrompt = showSignOutPrompt, showSignOutPrompt = showSignOutPrompt,
signOutAction = signOutAction.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun PinEntry.process(pinKeypadModel: PinKeypadModel): PinEntry { private fun Async<PinEntry>.isComplete(): Boolean {
return when (pinKeypadModel) { return dataOrNull()?.isComplete().orFalse()
PinKeypadModel.Back -> deleteLast() }
is PinKeypadModel.Number -> addDigit(pinKeypadModel.number)
PinKeypadModel.Empty -> this private fun Async<PinEntry>.toText(): String {
return dataOrNull()?.toText() ?: ""
}
private fun Async<PinEntry>.clear(): Async<PinEntry> {
return when (this) {
is Async.Success -> Async.Success(data.clear())
else -> this
} }
} }
private fun Async<PinEntry>.process(pinKeypadModel: PinKeypadModel): Async<PinEntry> {
return when (this) {
is Async.Success -> {
val pinEntry = when (pinKeypadModel) {
PinKeypadModel.Back -> data.deleteLast()
is PinKeypadModel.Number -> data.addDigit(pinKeypadModel.number)
PinKeypadModel.Empty -> data
}
Async.Success(pinEntry)
}
else -> this
}
}
private fun CoroutineScope.signOut(signOutAction: MutableState<Async<String?>>) = launch {
suspend {
matrixClient.logout()
}.runCatchingUpdatingState(signOutAction)
}
} }

View file

@ -17,13 +17,18 @@
package io.element.android.features.lockscreen.impl.unlock package io.element.android.features.lockscreen.impl.unlock
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.Async
data class PinUnlockState( data class PinUnlockState(
val pinEntry: PinEntry, val pinEntry: Async<PinEntry>,
val showWrongPinTitle: Boolean, val showWrongPinTitle: Boolean,
val remainingAttempts: Int, val remainingAttempts: Async<Int>,
val showSignOutPrompt: Boolean, val showSignOutPrompt: Boolean,
val signOutAction: Async<String?>,
val eventSink: (PinUnlockEvents) -> Unit val eventSink: (PinUnlockEvents) -> Unit
) { ) {
val isSignOutPromptCancellable = remainingAttempts > 0 val isSignOutPromptCancellable = when (remainingAttempts) {
is Async.Success -> remainingAttempts.data > 0
else -> true
}
} }

View file

@ -18,6 +18,7 @@ package io.element.android.features.lockscreen.impl.unlock
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.Async
open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> { open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
override val values: Sequence<PinUnlockState> override val values: Sequence<PinUnlockState>
@ -27,6 +28,7 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
aPinUnlockState(showWrongPinTitle = true), aPinUnlockState(showWrongPinTitle = true),
aPinUnlockState(showSignOutPrompt = true), aPinUnlockState(showSignOutPrompt = true),
aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0), aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0),
aPinUnlockState(signOutAction = Async.Loading()),
) )
} }
@ -35,10 +37,12 @@ fun aPinUnlockState(
remainingAttempts: Int = 3, remainingAttempts: Int = 3,
showWrongPinTitle: Boolean = false, showWrongPinTitle: Boolean = false,
showSignOutPrompt: Boolean = false, showSignOutPrompt: Boolean = false,
signOutAction: Async<String?> = Async.Uninitialized,
) = PinUnlockState( ) = PinUnlockState(
pinEntry = pinEntry, pinEntry = Async.Success(pinEntry),
showWrongPinTitle = showWrongPinTitle, showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts, remainingAttempts = Async.Success(remainingAttempts),
showSignOutPrompt = showSignOutPrompt, showSignOutPrompt = showSignOutPrompt,
signOutAction = signOutAction,
eventSink = {} eventSink = {}
) )

View file

@ -48,6 +48,8 @@ import io.element.android.features.lockscreen.impl.R
import io.element.android.features.lockscreen.impl.pin.model.PinDigit import io.element.android.features.lockscreen.impl.pin.model.PinDigit
import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypad import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypad
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -79,7 +81,13 @@ fun PinUnlockView(
} }
val footer = @Composable { val footer = @Composable {
PinUnlockFooter( PinUnlockFooter(
modifier = Modifier.padding(top = 24.dp) modifier = Modifier.padding(top = 24.dp),
onUseBiometric = {
state.eventSink(PinUnlockEvents.OnUseBiometric)
},
onForgotPin = {
state.eventSink(PinUnlockEvents.OnForgetPin)
},
) )
} }
val content = @Composable { constraints: BoxWithConstraintsScope -> val content = @Composable { constraints: BoxWithConstraintsScope ->
@ -107,23 +115,42 @@ fun PinUnlockView(
modifier = commonModifier, modifier = commonModifier,
) )
} }
if (state.showSignOutPrompt) {
if (state.isSignOutPromptCancellable) {
ConfirmationDialog(
title = stringResource(id = R.string.screen_app_lock_signout_alert_title),
content = stringResource(id = R.string.screen_app_lock_signout_alert_message),
onSubmitClicked = {},
onDismiss = {},
)
} else {
ErrorDialog(
title = stringResource(id = R.string.screen_app_lock_signout_alert_title),
content = stringResource(id = R.string.screen_app_lock_signout_alert_message),
onDismiss = {},
)
}
}
} }
if (state.showSignOutPrompt) {
SignOutPrompt(
isCancellable = state.isSignOutPromptCancellable,
onSignOut = { state.eventSink(PinUnlockEvents.SignOut) },
onDismiss = { state.eventSink(PinUnlockEvents.ClearSignOutPrompt) },
)
}
if (state.signOutAction is Async.Loading) {
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
}
}
}
@Composable
private fun SignOutPrompt(
isCancellable: Boolean,
onSignOut: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
if (isCancellable) {
ConfirmationDialog(
title = stringResource(id = R.string.screen_app_lock_signout_alert_title),
content = stringResource(id = R.string.screen_app_lock_signout_alert_message),
onSubmitClicked = onSignOut,
onDismiss = onDismiss,
modifier = modifier,
)
} else {
ErrorDialog(
title = stringResource(id = R.string.screen_app_lock_signout_alert_title),
content = stringResource(id = R.string.screen_app_lock_signout_alert_message),
onDismiss = onSignOut,
modifier = modifier,
)
} }
} }
@ -226,10 +253,15 @@ private fun PinUnlockHeader(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
val subtitle = if (state.showWrongPinTitle) { val remainingAttempts = state.remainingAttempts.dataOrNull()
pluralStringResource(id = R.plurals.screen_app_lock_subtitle_wrong_pin, count = state.remainingAttempts, state.remainingAttempts) val subtitle = if (remainingAttempts != null) {
if (state.showWrongPinTitle) {
pluralStringResource(id = R.plurals.screen_app_lock_subtitle_wrong_pin, count = remainingAttempts, remainingAttempts)
} else {
pluralStringResource(id = R.plurals.screen_app_lock_subtitle, count = remainingAttempts, remainingAttempts)
}
} else { } else {
stringResource(id = R.string.screen_app_lock_subtitle) ""
} }
val subtitleColor = if (state.showWrongPinTitle) { val subtitleColor = if (state.showWrongPinTitle) {
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
@ -244,17 +276,21 @@ private fun PinUnlockHeader(
color = subtitleColor, color = subtitleColor,
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
PinDotsRow(state.pinEntry) if (state.pinEntry is Async.Success) {
PinDotsRow(state.pinEntry.data)
}
} }
} }
@Composable @Composable
private fun PinUnlockFooter( private fun PinUnlockFooter(
onUseBiometric: () -> Unit,
onForgotPin: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) { Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
TextButton(text = "Use biometric", onClick = { }) TextButton(text = "Use biometric", onClick = onUseBiometric)
TextButton(text = stringResource(id = R.string.screen_app_lock_forgot_pin), onClick = { }) TextButton(text = stringResource(id = R.string.screen_app_lock_forgot_pin), onClick = onForgotPin)
} }
} }

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_in_progress_dialog_content">"Abmelden…"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_in_progress_dialog_content">"Cerrando sesión…"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_in_progress_dialog_content">"Déconnexion…"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_in_progress_dialog_content">"Uscita in corso…"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_in_progress_dialog_content">"Deconectare în curs…"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_in_progress_dialog_content">"Выполняется выход…"</string>
</resources>

View file

@ -1,16 +1,24 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_app_lock_subtitle">
<item quantity="other">"Máte 3 pokusy na odomknutie"</item>
</plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin"> <plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"Nesprávny PIN kód. Máte ešte %1$d pokus"</item> <item quantity="one">"Nesprávny PIN kód. Máte ešte %1$d pokus"</item>
<item quantity="few">"Nesprávny PIN kód. Máte ešte %1$d pokusy"</item> <item quantity="few">"Nesprávny PIN kód. Máte ešte %1$d pokusy"</item>
<item quantity="other">"Nesprávny PIN kód. Máte ešte %1$d pokusov"</item> <item quantity="other">"Nesprávny PIN kód. Máte ešte %1$d pokusov"</item>
</plurals> </plurals>
<string name="screen_app_lock_biometric_authentication">"biometrické overenie"</string>
<string name="screen_app_lock_biometric_unlock">"biometrické odomknutie"</string>
<string name="screen_app_lock_forgot_pin">"Zabudli ste PIN?"</string> <string name="screen_app_lock_forgot_pin">"Zabudli ste PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Zmeniť PIN kód"</string> <string name="screen_app_lock_settings_change_pin">"Zmeniť PIN kód"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Povoliť biometrické odomknutie"</string> <string name="screen_app_lock_settings_enable_biometric_unlock">"Povoliť biometrické odomknutie"</string>
<string name="screen_app_lock_settings_remove_pin">"Odstrániť PIN"</string> <string name="screen_app_lock_settings_remove_pin">"Odstrániť PIN"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Ste si istí, že chcete odstrániť PIN?"</string> <string name="screen_app_lock_settings_remove_pin_alert_message">"Ste si istí, že chcete odstrániť PIN?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Odstrániť PIN?"</string> <string name="screen_app_lock_settings_remove_pin_alert_title">"Odstrániť PIN?"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Povoliť %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"Radšej použijem PIN"</string>
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Ušetrite si čas a použite zakaždým %1$s na odomknutie aplikácie"</string>
<string name="screen_app_lock_setup_choose_pin">"Vyberte PIN"</string> <string name="screen_app_lock_setup_choose_pin">"Vyberte PIN"</string>
<string name="screen_app_lock_setup_confirm_pin">"Potvrdiť PIN"</string> <string name="screen_app_lock_setup_confirm_pin">"Potvrdiť PIN"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Z bezpečnostných dôvodov si nemôžete toto zvoliť ako svoj PIN kód."</string> <string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Z bezpečnostných dôvodov si nemôžete toto zvoliť ako svoj PIN kód."</string>
@ -22,5 +30,5 @@ Vyberte si niečo zapamätateľné. Ak tento kód PIN zabudnete, budete z aplik
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN kódy sa nezhodujú"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN kódy sa nezhodujú"</string>
<string name="screen_app_lock_signout_alert_message">"Ak chcete pokračovať, musíte sa znovu prihlásiť a vytvoriť nový PIN kód."</string> <string name="screen_app_lock_signout_alert_message">"Ak chcete pokračovať, musíte sa znovu prihlásiť a vytvoriť nový PIN kód."</string>
<string name="screen_app_lock_signout_alert_title">"Prebieha odhlasovanie"</string> <string name="screen_app_lock_signout_alert_title">"Prebieha odhlasovanie"</string>
<string name="screen_app_lock_subtitle">"Máte 3 pokusy na odomknutie"</string> <string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string>
</resources> </resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_in_progress_dialog_content">"正在登出…"</string>
</resources>

View file

@ -1,15 +1,24 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_app_lock_subtitle">
<item quantity="one">"You have %1$d attempt to unlock"</item>
<item quantity="other">"You have %1$d attempts to unlock"</item>
</plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin"> <plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"Wrong PIN. You have %1$d more chance"</item> <item quantity="one">"Wrong PIN. You have %1$d more chance"</item>
<item quantity="other">"Wrong PIN. You have %1$d more chances"</item> <item quantity="other">"Wrong PIN. You have %1$d more chances"</item>
</plurals> </plurals>
<string name="screen_app_lock_biometric_authentication">"biometric authentication"</string>
<string name="screen_app_lock_biometric_unlock">"biometric unlock"</string>
<string name="screen_app_lock_forgot_pin">"Forgot PIN?"</string> <string name="screen_app_lock_forgot_pin">"Forgot PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Change PIN code"</string> <string name="screen_app_lock_settings_change_pin">"Change PIN code"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Allow biometric unlock"</string> <string name="screen_app_lock_settings_enable_biometric_unlock">"Allow biometric unlock"</string>
<string name="screen_app_lock_settings_remove_pin">"Remove PIN"</string> <string name="screen_app_lock_settings_remove_pin">"Remove PIN"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Are you sure you want to remove PIN?"</string> <string name="screen_app_lock_settings_remove_pin_alert_message">"Are you sure you want to remove PIN?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Remove PIN?"</string> <string name="screen_app_lock_settings_remove_pin_alert_title">"Remove PIN?"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Allow %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"Id rather use PIN"</string>
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Save yourself some time and use %1$s to unlock the app each time"</string>
<string name="screen_app_lock_setup_choose_pin">"Choose PIN"</string> <string name="screen_app_lock_setup_choose_pin">"Choose PIN"</string>
<string name="screen_app_lock_setup_confirm_pin">"Confirm PIN"</string> <string name="screen_app_lock_setup_confirm_pin">"Confirm PIN"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"You cannot choose this as your PIN code for security reasons"</string> <string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"You cannot choose this as your PIN code for security reasons"</string>
@ -21,5 +30,5 @@ Choose something memorable. If you forget this PIN, you will be logged out of th
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PINs don\'t match"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PINs don\'t match"</string>
<string name="screen_app_lock_signout_alert_message">"Youll need to re-login and create a new PIN to proceed"</string> <string name="screen_app_lock_signout_alert_message">"Youll need to re-login and create a new PIN to proceed"</string>
<string name="screen_app_lock_signout_alert_title">"You are being signed out"</string> <string name="screen_app_lock_signout_alert_title">"You are being signed out"</string>
<string name="screen_app_lock_subtitle">"You have 3 attempts to unlock"</string> <string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
</resources> </resources>

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.fixtures
import io.element.android.appconfig.LockScreenConfig
internal fun aLockScreenConfig(
isPinMandatory: Boolean = false,
pinBlacklist: Set<String> = emptySet(),
pinSize: Int = 4,
maxPinCodeAttemptsBeforeLogout: Int = 3,
gracePeriodInMillis: Long = 5 * 60 * 1000L
): LockScreenConfig {
return LockScreenConfig(
isPinMandatory = isPinMandatory,
pinBlacklist = pinBlacklist,
pinSize = pinSize,
maxPinCodeAttemptsBeforeLogout = maxPinCodeAttemptsBeforeLogout,
gracePeriodInMillis = gracePeriodInMillis
)
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.fixtures
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryPinCodeStore
import io.element.android.features.lockscreen.impl.pin.storage.PinCodeStore
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService
import io.element.android.libraries.cryptography.test.SimpleSecretKeyProvider
internal fun aPinCodeManager(
pinCodeStore: PinCodeStore = InMemoryPinCodeStore(),
secretKeyProvider: SimpleSecretKeyProvider = SimpleSecretKeyProvider(),
encryptionDecryptionService: EncryptionDecryptionService = AESEncryptionDecryptionService(),
): PinCodeManager {
return DefaultPinCodeManager(secretKeyProvider, encryptionDecryptionService, pinCodeStore)
}

View file

@ -0,0 +1,81 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.pin.model
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class PinEntryTest {
@Test
fun `when using fillWith with empty string ensure pin is empty`() {
val pinEntry = PinEntry.createEmpty(4)
val newPinEntry = pinEntry.fillWith("")
assertThat(newPinEntry.isEmpty()).isTrue()
}
@Test
fun `when using fillWith with bigger string than size ensure pin is complete`() {
val pinEntry = PinEntry.createEmpty(4)
val newPinEntry = pinEntry.fillWith("12345")
assertThat(newPinEntry.isComplete()).isTrue()
newPinEntry.assertText("1234")
}
@Test
fun `when using fillWith with non digit string ensure pin is filtering`() {
val pinEntry = PinEntry.createEmpty(4)
val newPinEntry = pinEntry.fillWith("12aa")
newPinEntry.assertText("12")
}
@Test
fun `when using clear ensure pin is empty`() {
val pinEntry = PinEntry.createEmpty(4)
val newPinEntry = pinEntry.clear()
assertThat(newPinEntry.isEmpty()).isTrue()
assertThat(newPinEntry.isComplete()).isFalse()
newPinEntry.assertText("")
}
@Test
fun `when using deleteLast ensure pin correct`() {
val pinEntry = PinEntry.createEmpty(4)
val newPinEntry = pinEntry.fillWith("1234").deleteLast()
newPinEntry.assertText("123")
}
@Test
fun `when using deleteLast with empty pin ensure pin is empty`() {
val pinEntry = PinEntry.createEmpty(4)
val newPinEntry = pinEntry.deleteLast()
assertThat(newPinEntry.isEmpty()).isTrue()
}
@Test
fun `when using addDigit with complete pin ensure pin is complete`() {
val pinEntry = PinEntry.createEmpty(4)
val newPinEntry = pinEntry
.addDigit('1')
.addDigit('2')
.addDigit('3')
.addDigit('4')
.addDigit('5')
assertThat(newPinEntry.isComplete()).isTrue()
newPinEntry.assertText("1234")
}
}

View file

@ -27,22 +27,14 @@ class InMemoryPinCodeStore : PinCodeStore {
return remainingAttempts return remainingAttempts
} }
override suspend fun onWrongPin(): Int { override suspend fun onWrongPin() {
return remainingAttempts-- remainingAttempts--
} }
override suspend fun resetCounter() { override suspend fun resetCounter() {
remainingAttempts = DEFAULT_REMAINING_ATTEMPTS remainingAttempts = DEFAULT_REMAINING_ATTEMPTS
} }
override fun addListener(listener: PinCodeStore.Listener) {
// no-op
}
override fun removeListener(listener: PinCodeStore.Listener) {
// no-op
}
override suspend fun getEncryptedCode(): String? { override suspend fun getEncryptedCode(): String? {
return pinCode return pinCode
} }

View file

@ -0,0 +1,79 @@
/*
* Copyright (c) 2023 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.lockscreen.impl.settings
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LockScreenSettingsPresenterTest {
@Test
fun `present - remove pin flow`() = runTest {
val presenter = createLockScreenSettingsPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
consumeItemsUntilPredicate { state ->
state.showRemovePinOption
}.last().also { state ->
state.eventSink(LockScreenSettingsEvents.OnRemovePin)
}
awaitLastSequentialItem().also { state ->
assertThat(state.showRemovePinConfirmation).isTrue()
state.eventSink(LockScreenSettingsEvents.CancelRemovePin)
}
awaitLastSequentialItem().also { state ->
assertThat(state.showRemovePinConfirmation).isFalse()
state.eventSink(LockScreenSettingsEvents.OnRemovePin)
}
awaitLastSequentialItem().also { state ->
assertThat(state.showRemovePinConfirmation).isTrue()
state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin)
}
consumeItemsUntilPredicate {
it.showRemovePinOption.not()
}.last().also { state ->
assertThat(state.showRemovePinConfirmation).isFalse()
assertThat(state.showRemovePinOption).isFalse()
}
}
}
private suspend fun createLockScreenSettingsPresenter(
coroutineScope: CoroutineScope,
lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
): LockScreenSettingsPresenter {
val pinCodeManager = aPinCodeManager().apply {
createPinCode("1234")
}
return LockScreenSettingsPresenter(
pinCodeManager = pinCodeManager,
coroutineScope = coroutineScope,
lockScreenConfig = lockScreenConfig,
)
}
}

View file

@ -20,12 +20,19 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.assertEmpty import io.element.android.features.lockscreen.impl.pin.model.assertEmpty
import io.element.android.features.lockscreen.impl.pin.model.assertText import io.element.android.features.lockscreen.impl.pin.model.assertText
import io.element.android.features.lockscreen.impl.setup.validation.PinValidator import io.element.android.features.lockscreen.impl.setup.validation.PinValidator
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -38,8 +45,13 @@ class SetupPinPresenterTest {
@Test @Test
fun `present - complete flow`() = runTest { fun `present - complete flow`() = runTest {
val pinCodeCreated = CompletableDeferred<Unit>()
val presenter = createSetupPinPresenter() val callback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
pinCodeCreated.complete(Unit)
}
}
val presenter = createSetupPinPresenter(callback)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -67,7 +79,9 @@ class SetupPinPresenterTest {
assertThat(state.setupPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin)) state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
} }
awaitLastSequentialItem().also { state -> consumeItemsUntilPredicate {
it.isConfirmationStep
}.last().also { state ->
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue() assertThat(state.isConfirmationStep).isTrue()
@ -86,7 +100,9 @@ class SetupPinPresenterTest {
assertThat(state.setupPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin)) state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
} }
awaitLastSequentialItem().also { state -> consumeItemsUntilPredicate {
it.isConfirmationStep
}.last().also { state ->
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue() assertThat(state.isConfirmationStep).isTrue()
@ -96,10 +112,23 @@ class SetupPinPresenterTest {
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertText(completePin) state.confirmPinEntry.assertText(completePin)
} }
pinCodeCreated.await()
} }
} }
private fun createSetupPinPresenter(): SetupPinPresenter { private fun createSetupPinPresenter(
return SetupPinPresenter(PinValidator(setOf(blacklistedPin)), aBuildMeta()) callback: PinCodeManager.Callback,
lockScreenConfig: LockScreenConfig = aLockScreenConfig(
pinBlacklist = setOf(blacklistedPin)
),
): SetupPinPresenter {
val pinCodeManager = aPinCodeManager()
pinCodeManager.addCallback(callback)
return SetupPinPresenter(
lockScreenConfig = lockScreenConfig,
pinValidator = PinValidator(lockScreenConfig),
buildMeta = aBuildMeta(),
pinCodeManager = pinCodeManager
)
} }
} }

View file

@ -20,13 +20,17 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.pin.model.assertEmpty import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.pin.model.assertText import io.element.android.features.lockscreen.impl.pin.model.assertText
import io.element.android.features.lockscreen.impl.state.DefaultLockScreenStateService
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.architecture.Async
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -37,16 +41,27 @@ class PinUnlockPresenterTest {
private val completePin = "1235" private val completePin = "1235"
@Test @Test
fun `present - complete flow`() = runTest { fun `present - success verify flow`() = runTest {
val presenter = createPinUnlockPresenter(this) val pinCodeVerified = CompletableDeferred<Unit>()
val callback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
pinCodeVerified.complete(Unit)
}
}
val presenter = createPinUnlockPresenter(this, callback)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitItem().also { state -> awaitItem().also { state ->
state.pinEntry.assertEmpty() assertThat(state.pinEntry).isInstanceOf(Async.Uninitialized::class.java)
assertThat(state.showWrongPinTitle).isFalse() assertThat(state.showWrongPinTitle).isFalse()
assertThat(state.showSignOutPrompt).isFalse() assertThat(state.showSignOutPrompt).isFalse()
assertThat(state.remainingAttempts).isEqualTo(3) assertThat(state.signOutAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(state.remainingAttempts).isInstanceOf(Async.Uninitialized::class.java)
}
consumeItemsUntilPredicate {
it.pinEntry is Async.Success && it.remainingAttempts is Async.Success
}.last().also { state ->
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1'))) state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2'))) state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
} }
@ -55,9 +70,55 @@ class PinUnlockPresenterTest {
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3'))) state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back)) state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Empty)) state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Empty))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5')))
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.pinEntry.assertText(halfCompletePin) state.pinEntry.assertText(completePin)
}
pinCodeVerified.await()
}
}
@Test
fun `present - failure verify flow`() = runTest {
val pinCodeVerified = CompletableDeferred<Unit>()
val callback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
pinCodeVerified.complete(Unit)
}
}
val presenter = createPinUnlockPresenter(this, callback)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = consumeItemsUntilPredicate {
it.pinEntry is Async.Success && it.remainingAttempts is Async.Success
}.last()
val numberOfAttempts = initialState.remainingAttempts.dataOrNull() ?: 0
repeat(numberOfAttempts) {
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('4')))
}
awaitLastSequentialItem().also { state ->
assertThat(state.remainingAttempts.dataOrNull()).isEqualTo(0)
assertThat(state.showSignOutPrompt).isEqualTo(true)
assertThat(state.isSignOutPromptCancellable).isEqualTo(false)
}
}
}
@Test
fun `present - forgot pin flow`() = runTest {
val presenter = createPinUnlockPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
consumeItemsUntilPredicate {
it.pinEntry is Async.Success && it.remainingAttempts is Async.Success
}.last().also { state ->
state.eventSink(PinUnlockEvents.OnForgetPin) state.eventSink(PinUnlockEvents.OnForgetPin)
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
@ -67,22 +128,33 @@ class PinUnlockPresenterTest {
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
assertThat(state.showSignOutPrompt).isEqualTo(false) assertThat(state.showSignOutPrompt).isEqualTo(false)
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3'))) state.eventSink(PinUnlockEvents.OnForgetPin)
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5')))
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.pinEntry.assertText(completePin) assertThat(state.showSignOutPrompt).isEqualTo(true)
state.eventSink(PinUnlockEvents.SignOut)
}
consumeItemsUntilPredicate { state ->
state.signOutAction is Async.Success
} }
} }
} }
private suspend fun createPinUnlockPresenter(scope: CoroutineScope): PinUnlockPresenter { private fun Async<PinEntry>.assertText(text: String) {
val featureFlagService = FakeFeatureFlagService().apply { dataOrNull()?.assertText(text)
setFeatureEnabled(FeatureFlags.PinUnlock, true) }
private suspend fun createPinUnlockPresenter(
scope: CoroutineScope,
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
): PinUnlockPresenter {
val pinCodeManager = aPinCodeManager().apply {
addCallback(callback)
createPinCode(completePin)
} }
val lockScreenStateService = DefaultLockScreenStateService(featureFlagService)
return PinUnlockPresenter( return PinUnlockPresenter(
lockScreenStateService, pinCodeManager,
FakeMatrixClient(),
scope, scope,
) )
} }

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.lockscreen.test"
}
dependencies {
implementation(libs.coroutines.core)
api(projects.features.lockscreen.api)
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 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.lockscreen.test
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FakeLockScreenService : LockScreenService {
private var isSetupRequired: Boolean = false
private val _lockState: MutableStateFlow<LockScreenLockState> = MutableStateFlow(LockScreenLockState.Locked)
override val lockState: StateFlow<LockScreenLockState> = _lockState
override suspend fun isSetupRequired(): Boolean {
return isSetupRequired
}
fun setIsSetupRequired(isSetupRequired: Boolean) {
this.isSetupRequired = isSetupRequired
}
fun setLockState(lockState: LockScreenLockState) {
_lockState.value = lockState
}
}

View file

@ -35,8 +35,8 @@
<string name="screen_waitlist_message">"Na %2$s je momentálně vysoká poptávka po %1$s. Vraťte se do aplikace za pár dní a zkuste to znovu. <string name="screen_waitlist_message">"Na %2$s je momentálně vysoká poptávka po %1$s. Vraťte se do aplikace za pár dní a zkuste to znovu.
Díky za trpělivost!"</string> Díky za trpělivost!"</string>
<string name="screen_waitlist_message_success">"Vítá vás %1$s"</string>
<string name="screen_waitlist_title">"Jste v pořadníku!"</string> <string name="screen_waitlist_title">"Jste v pořadníku!"</string>
<string name="screen_waitlist_title_success">"Jdete do toho!"</string> <string name="screen_waitlist_title_success">"Jdete do toho!"</string>
<string name="screen_login_subtitle">"Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci."</string> <string name="screen_login_subtitle">"Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci."</string>
<string name="screen_waitlist_message_success">"Vítá vás %1$s!"</string>
</resources> </resources>

View file

@ -34,8 +34,8 @@
<string name="screen_waitlist_message">"Derzeit besteht eine hohe Nachfrage nach %1$s auf %2$s. Kehre in ein paar Tagen zur App zurück und versuche es erneut. <string name="screen_waitlist_message">"Derzeit besteht eine hohe Nachfrage nach %1$s auf %2$s. Kehre in ein paar Tagen zur App zurück und versuche es erneut.
Danke für deine Geduld!"</string> Danke für deine Geduld!"</string>
<string name="screen_waitlist_message_success">"Willkommen bei %1$s!"</string>
<string name="screen_waitlist_title">"Du bist fast am Ziel."</string> <string name="screen_waitlist_title">"Du bist fast am Ziel."</string>
<string name="screen_waitlist_title_success">"Du bist dabei."</string> <string name="screen_waitlist_title_success">"Du bist dabei."</string>
<string name="screen_login_subtitle">"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."</string> <string name="screen_login_subtitle">"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."</string>
<string name="screen_waitlist_message_success">"Willkommen bei %1$s!"</string>
</resources> </resources>

View file

@ -34,8 +34,8 @@
<string name="screen_waitlist_message">"Il y a une forte demande pour %1$s sur %2$s à lheure actuelle. Revenez sur lapplication dans quelques jours et réessayez. <string name="screen_waitlist_message">"Il y a une forte demande pour %1$s sur %2$s à lheure actuelle. Revenez sur lapplication dans quelques jours et réessayez.
Merci pour votre patience !"</string> Merci pour votre patience !"</string>
<string name="screen_waitlist_message_success">"Bienvenue dans %1$s !"</string>
<string name="screen_waitlist_title">"Vous y êtes presque."</string> <string name="screen_waitlist_title">"Vous y êtes presque."</string>
<string name="screen_waitlist_title_success">"Vous y êtes."</string> <string name="screen_waitlist_title_success">"Vous y êtes."</string>
<string name="screen_login_subtitle">"Matrix est un réseau ouvert pour une communication sécurisée et décentralisée."</string> <string name="screen_login_subtitle">"Matrix est un réseau ouvert pour une communication sécurisée et décentralisée."</string>
<string name="screen_waitlist_message_success">"Bienvenue dans %1$s !"</string>
</resources> </resources>

View file

@ -34,8 +34,8 @@
<string name="screen_waitlist_message">"Există o cerere mare pentru %1$s pentru %2$s în acest moment. Reveniți la aplicație în câteva zile și încercați din nou. <string name="screen_waitlist_message">"Există o cerere mare pentru %1$s pentru %2$s în acest moment. Reveniți la aplicație în câteva zile și încercați din nou.
Vă mulțumim pentru răbdare!"</string> Vă mulțumim pentru răbdare!"</string>
<string name="screen_waitlist_message_success">"Bun venit la %1$s"</string>
<string name="screen_waitlist_title">"Sunteți pe lista de așteptare"</string> <string name="screen_waitlist_title">"Sunteți pe lista de așteptare"</string>
<string name="screen_waitlist_title_success">"Sunteți conectat!"</string> <string name="screen_waitlist_title_success">"Sunteți conectat!"</string>
<string name="screen_login_subtitle">"Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată."</string> <string name="screen_login_subtitle">"Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată."</string>
<string name="screen_waitlist_message_success">"Bun venit la%1$s!"</string>
</resources> </resources>

View file

@ -35,8 +35,8 @@
<string name="screen_waitlist_message">"В настоящее время существует высокий спрос на %1$s на %2$s. Вернитесь в приложение через несколько дней и попробуйте снова. <string name="screen_waitlist_message">"В настоящее время существует высокий спрос на %1$s на %2$s. Вернитесь в приложение через несколько дней и попробуйте снова.
Спасибо за терпение!"</string> Спасибо за терпение!"</string>
<string name="screen_waitlist_message_success">"Добро пожаловать в %1$s!"</string>
<string name="screen_waitlist_title">"Почти готово!"</string> <string name="screen_waitlist_title">"Почти готово!"</string>
<string name="screen_waitlist_title_success">"Вы зарегистрированы!"</string> <string name="screen_waitlist_title_success">"Вы зарегистрированы!"</string>
<string name="screen_login_subtitle">"Matrix — это открытая сеть для безопасной децентрализованной связи."</string> <string name="screen_login_subtitle">"Matrix — это открытая сеть для безопасной децентрализованной связи."</string>
<string name="screen_waitlist_message_success">"Добро пожаловать в %1$s!"</string>
</resources> </resources>

View file

@ -35,8 +35,8 @@
<string name="screen_waitlist_message">"Momentálne je veľký dopyt po %1$s na %2$s. Vráťte sa do aplikácie za pár dní a skúste to znova. <string name="screen_waitlist_message">"Momentálne je veľký dopyt po %1$s na %2$s. Vráťte sa do aplikácie za pár dní a skúste to znova.
Ďakujeme za trpezlivosť!"</string> Ďakujeme za trpezlivosť!"</string>
<string name="screen_waitlist_message_success">"Vitajte v %1$s"</string>
<string name="screen_waitlist_title">"Ste na čakanej listine!"</string> <string name="screen_waitlist_title">"Ste na čakanej listine!"</string>
<string name="screen_waitlist_title_success">"Ste dnu!"</string> <string name="screen_waitlist_title_success">"Ste dnu!"</string>
<string name="screen_login_subtitle">"Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string> <string name="screen_login_subtitle">"Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string>
<string name="screen_waitlist_message_success">"Vitajte v %1$s!"</string>
</resources> </resources>

View file

@ -25,6 +25,6 @@
<string name="screen_server_confirmation_message_register">"您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。"</string> <string name="screen_server_confirmation_message_register">"您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。"</string>
<string name="screen_server_confirmation_title_login">"您即將登入 %1$s"</string> <string name="screen_server_confirmation_title_login">"您即將登入 %1$s"</string>
<string name="screen_server_confirmation_title_register">"您即將在 %1$s 建立帳號"</string> <string name="screen_server_confirmation_title_register">"您即將在 %1$s 建立帳號"</string>
<string name="screen_waitlist_message_success">"歡迎使用 %1$s"</string>
<string name="screen_login_subtitle">"Matrix 是一個開放網路,為了安全、去中心化的通訊而生。"</string> <string name="screen_login_subtitle">"Matrix 是一個開放網路,為了安全、去中心化的通訊而生。"</string>
<string name="screen_waitlist_message_success">"歡迎使用 %1$s"</string>
</resources> </resources>

View file

@ -35,8 +35,8 @@
<string name="screen_waitlist_message">"There\'s a high demand for %1$s on %2$s at the moment. Come back to the app in a few days and try again. <string name="screen_waitlist_message">"There\'s a high demand for %1$s on %2$s at the moment. Come back to the app in a few days and try again.
Thanks for your patience!"</string> Thanks for your patience!"</string>
<string name="screen_waitlist_message_success">"Welcome to %1$s!"</string>
<string name="screen_waitlist_title">"Youre almost there."</string> <string name="screen_waitlist_title">"Youre almost there."</string>
<string name="screen_waitlist_title_success">"You\'re in."</string> <string name="screen_waitlist_title_success">"You\'re in."</string>
<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_waitlist_message_success">"Welcome to %1$s!"</string>
</resources> </resources>

View file

@ -1,12 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_backing_up_subtitle">"Prosím, počkajte na dokončenie tohto kroku a až potom sa odhláste."</string>
<string name="screen_signout_backing_up_title">"Vaše kľúče sa ešte stále zálohujú"</string>
<string name="screen_signout_confirmation_dialog_content">"Ste si istí, že sa chcete odhlásiť?"</string> <string name="screen_signout_confirmation_dialog_content">"Ste si istí, že sa chcete odhlásiť?"</string>
<string name="screen_signout_confirmation_dialog_title">"Odhlásiť sa"</string> <string name="screen_signout_confirmation_dialog_title">"Odhlásiť sa"</string>
<string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string> <string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string>
<string name="screen_signout_last_session_subtitle">"Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, môžete stratiť prístup k svojim šifrovaným správam."</string> <string name="screen_signout_key_backup_disabled_subtitle">"Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, stratíte prístup k svojim šifrovaným správam."</string>
<string name="screen_signout_last_session_title">"Uložili ste kľúč na obnovenie?"</string> <string name="screen_signout_key_backup_disabled_title">"Vypli ste zálohovanie"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Keď ste sa odpojili od internetu, vaše kľúče sa ešte stále zálohovali. Pripojte sa znova k internetu, aby sa vaše kľúče mohli zálohovať pred odhlásením."</string>
<string name="screen_signout_key_backup_offline_title">"Vaše kľúče sa ešte stále zálohujú"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Pred odhlásením počkajte, kým sa to dokončí."</string>
<string name="screen_signout_key_backup_ongoing_title">"Vaše kľúče sa ešte stále zálohujú"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, stratíte prístup k svojim šifrovaným správam."</string>
<string name="screen_signout_recovery_disabled_title">"Obnovenie nie je nastavené"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, môžete stratiť prístup k svojim šifrovaným správam."</string>
<string name="screen_signout_save_recovery_key_title">"Uložili ste si kľúč na obnovenie?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Odhlásiť sa"</string> <string name="screen_signout_confirmation_dialog_submit">"Odhlásiť sa"</string>
<string name="screen_signout_preference_item">"Odhlásiť sa"</string> <string name="screen_signout_preference_item">"Odhlásiť sa"</string>
</resources> </resources>

View file

@ -1,12 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_backing_up_subtitle">"Please wait for this to complete before signing out."</string>
<string name="screen_signout_backing_up_title">"Your keys are still being backed up"</string>
<string name="screen_signout_confirmation_dialog_content">"Are you sure you want to sign out?"</string> <string name="screen_signout_confirmation_dialog_content">"Are you sure you want to sign out?"</string>
<string name="screen_signout_confirmation_dialog_title">"Sign out"</string> <string name="screen_signout_confirmation_dialog_title">"Sign out"</string>
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string> <string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
<string name="screen_signout_last_session_subtitle">"You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages."</string> <string name="screen_signout_key_backup_disabled_subtitle">"You are about to sign out of your last session. If you sign out now, you will lose access to your encrypted messages."</string>
<string name="screen_signout_last_session_title">"Recovery not set up"</string> <string name="screen_signout_key_backup_disabled_title">"You have turned off backup"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Your keys were still being backed up when you went offline. Reconnect so that your keys can be backed up before signing out."</string>
<string name="screen_signout_key_backup_offline_title">"Your keys are still being backed up"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Please wait for this to complete before signing out."</string>
<string name="screen_signout_key_backup_ongoing_title">"Your keys are still being backed up"</string>
<string name="screen_signout_recovery_disabled_subtitle">"You are about to sign out of your last session. If you sign out now, you\'ll lose access to your encrypted messages."</string>
<string name="screen_signout_recovery_disabled_title">"Recovery not set up"</string>
<string name="screen_signout_save_recovery_key_subtitle">"You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages."</string>
<string name="screen_signout_save_recovery_key_title">"Have you saved your recovery key?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Sign out"</string> <string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
<string name="screen_signout_preference_item">"Sign out"</string> <string name="screen_signout_preference_item">"Sign out"</string>
</resources> </resources>

View file

@ -30,7 +30,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu."</string> <string name="screen_room_notification_settings_error_restoring_default">"Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Nastavení režimu se nezdařilo, zkuste to prosím znovu."</string> <string name="screen_room_notification_settings_error_setting_mode">"Nastavení režimu se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Všechny zprávy"</string> <string name="screen_room_notification_settings_mode_all_messages">"Všechny zprávy"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Pouze zmínky a klíčová slova"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"V této místnosti mě upozornit na"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"V této místnosti mě upozornit na"</string>
<string name="screen_room_reactions_show_less">"Zobrazit méně"</string> <string name="screen_room_reactions_show_less">"Zobrazit méně"</string>
<string name="screen_room_reactions_show_more">"Zobrazit více"</string> <string name="screen_room_reactions_show_more">"Zobrazit více"</string>
@ -40,4 +39,5 @@
<string name="screen_room_timeline_less_reactions">"Zobrazit méně"</string> <string name="screen_room_timeline_less_reactions">"Zobrazit méně"</string>
<string name="screen_room_voice_message_tooltip">"Držte pro nahrávání"</string> <string name="screen_room_voice_message_tooltip">"Držte pro nahrávání"</string>
<string name="screen_room_error_failed_processing_media">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string> <string name="screen_room_error_failed_processing_media">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Pouze zmínky a klíčová slova"</string>
</resources> </resources>

View file

@ -29,7 +29,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Fehler beim Wiederherstellen des Standardmodus. Bitte versuche es erneut."</string> <string name="screen_room_notification_settings_error_restoring_default">"Fehler beim Wiederherstellen des Standardmodus. Bitte versuche es erneut."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Fehler beim Einstellen des Modus. Bitte versuche es erneut."</string> <string name="screen_room_notification_settings_error_setting_mode">"Fehler beim Einstellen des Modus. Bitte versuche es erneut."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Alle Nachrichten"</string> <string name="screen_room_notification_settings_mode_all_messages">"Alle Nachrichten"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Nur Erwähnungen und Schlüsselwörter"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Benachrichtige mich in diesem Raum bei"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"Benachrichtige mich in diesem Raum bei"</string>
<string name="screen_room_reactions_show_less">"Weniger anzeigen"</string> <string name="screen_room_reactions_show_less">"Weniger anzeigen"</string>
<string name="screen_room_reactions_show_more">"Mehr anzeigen"</string> <string name="screen_room_reactions_show_more">"Mehr anzeigen"</string>
@ -38,4 +37,5 @@
<string name="screen_room_timeline_add_reaction">"Emoji hinzufügen"</string> <string name="screen_room_timeline_add_reaction">"Emoji hinzufügen"</string>
<string name="screen_room_timeline_less_reactions">"Weniger anzeigen"</string> <string name="screen_room_timeline_less_reactions">"Weniger anzeigen"</string>
<string name="screen_room_error_failed_processing_media">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string> <string name="screen_room_error_failed_processing_media">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Nur Erwähnungen und Schlüsselwörter"</string>
</resources> </resources>

View file

@ -29,7 +29,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Échec de la restauration du mode par défaut, veuillez réessayer."</string> <string name="screen_room_notification_settings_error_restoring_default">"Échec de la restauration du mode par défaut, veuillez réessayer."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Échec de la configuration du mode, veuillez réessayer."</string> <string name="screen_room_notification_settings_error_setting_mode">"Échec de la configuration du mode, veuillez réessayer."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Tous les messages"</string> <string name="screen_room_notification_settings_mode_all_messages">"Tous les messages"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions et mots clés uniquement"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Dans ce salon, prévenez-moi pour"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"Dans ce salon, prévenez-moi pour"</string>
<string name="screen_room_reactions_show_less">"Afficher moins"</string> <string name="screen_room_reactions_show_less">"Afficher moins"</string>
<string name="screen_room_reactions_show_more">"Afficher plus"</string> <string name="screen_room_reactions_show_more">"Afficher plus"</string>
@ -38,4 +37,5 @@
<string name="screen_room_timeline_add_reaction">"Ajouter un émoji"</string> <string name="screen_room_timeline_add_reaction">"Ajouter un émoji"</string>
<string name="screen_room_timeline_less_reactions">"Afficher moins"</string> <string name="screen_room_timeline_less_reactions">"Afficher moins"</string>
<string name="screen_room_error_failed_processing_media">"Échec du traitement des médias à télécharger, veuillez réessayer."</string> <string name="screen_room_error_failed_processing_media">"Échec du traitement des médias à télécharger, veuillez réessayer."</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions et mots clés uniquement"</string>
</resources> </resources>

View file

@ -30,7 +30,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Nu s-a reușit restaurarea modului implicit, vă rugăm să încercați din nou."</string> <string name="screen_room_notification_settings_error_restoring_default">"Nu s-a reușit restaurarea modului implicit, vă rugăm să încercați din nou."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Nu s-a reușit setarea modului, vă rugăm să încercați din nou."</string> <string name="screen_room_notification_settings_error_setting_mode">"Nu s-a reușit setarea modului, vă rugăm să încercați din nou."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Toate mesajele"</string> <string name="screen_room_notification_settings_mode_all_messages">"Toate mesajele"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Numai mențiuni și cuvinte cheie"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"În această cameră, anunțați-mă pentru"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"În această cameră, anunțați-mă pentru"</string>
<string name="screen_room_reactions_show_less">"Afișați mai puțin"</string> <string name="screen_room_reactions_show_less">"Afișați mai puțin"</string>
<string name="screen_room_reactions_show_more">"Afișați mai mult"</string> <string name="screen_room_reactions_show_more">"Afișați mai mult"</string>
@ -39,4 +38,5 @@
<string name="screen_room_timeline_add_reaction">"Adăugați emoji"</string> <string name="screen_room_timeline_add_reaction">"Adăugați emoji"</string>
<string name="screen_room_timeline_less_reactions">"Afișați mai puțin"</string> <string name="screen_room_timeline_less_reactions">"Afișați mai puțin"</string>
<string name="screen_room_error_failed_processing_media">"Procesarea datelor media a eșuat, vă rugăm să încercați din nou."</string> <string name="screen_room_error_failed_processing_media">"Procesarea datelor media a eșuat, vă rugăm să încercați din nou."</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Numai mențiuni și cuvinte cheie"</string>
</resources> </resources>

View file

@ -30,7 +30,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Не удалось восстановить режим по умолчанию, попробуйте еще раз."</string> <string name="screen_room_notification_settings_error_restoring_default">"Не удалось восстановить режим по умолчанию, попробуйте еще раз."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Не удалось настроить режим, попробуйте еще раз."</string> <string name="screen_room_notification_settings_error_setting_mode">"Не удалось настроить режим, попробуйте еще раз."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Все сообщения"</string> <string name="screen_room_notification_settings_mode_all_messages">"Все сообщения"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Только упоминания и ключевые слова"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"В этой комнате уведомить меня о"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"В этой комнате уведомить меня о"</string>
<string name="screen_room_reactions_show_less">"Показать меньше"</string> <string name="screen_room_reactions_show_less">"Показать меньше"</string>
<string name="screen_room_reactions_show_more">"Показать больше"</string> <string name="screen_room_reactions_show_more">"Показать больше"</string>
@ -40,4 +39,5 @@
<string name="screen_room_timeline_less_reactions">"Показать меньше"</string> <string name="screen_room_timeline_less_reactions">"Показать меньше"</string>
<string name="screen_room_voice_message_tooltip">"Удерживайте для записи"</string> <string name="screen_room_voice_message_tooltip">"Удерживайте для записи"</string>
<string name="screen_room_error_failed_processing_media">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string> <string name="screen_room_error_failed_processing_media">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Только упоминания и ключевые слова"</string>
</resources> </resources>

View file

@ -14,6 +14,7 @@
<string name="screen_room_attachment_source_poll">"Anketa"</string> <string name="screen_room_attachment_source_poll">"Anketa"</string>
<string name="screen_room_attachment_text_formatting">"Formátovanie textu"</string> <string name="screen_room_attachment_text_formatting">"Formátovanie textu"</string>
<string name="screen_room_encrypted_history_banner">"História správ v tejto miestnosti nie je momentálne k dispozícii"</string> <string name="screen_room_encrypted_history_banner">"História správ v tejto miestnosti nie je momentálne k dispozícii"</string>
<string name="screen_room_encrypted_history_banner_unverified">"História správ nie je v tejto miestnosti k dispozícii. Ak chcete zobraziť históriu správ, overte toto zariadenie."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Nepodarilo sa získať údaje o používateľovi"</string> <string name="screen_room_error_failed_retrieving_user_details">"Nepodarilo sa získať údaje o používateľovi"</string>
<string name="screen_room_invite_again_alert_message">"Chceli by ste ich pozvať späť?"</string> <string name="screen_room_invite_again_alert_message">"Chceli by ste ich pozvať späť?"</string>
<string name="screen_room_invite_again_alert_title">"V tomto rozhovore ste sami"</string> <string name="screen_room_invite_again_alert_title">"V tomto rozhovore ste sami"</string>
@ -30,7 +31,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Nepodarilo sa obnoviť predvolený režim, skúste to prosím znova."</string> <string name="screen_room_notification_settings_error_restoring_default">"Nepodarilo sa obnoviť predvolený režim, skúste to prosím znova."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Nepodarilo sa nastaviť režim, skúste to prosím znova."</string> <string name="screen_room_notification_settings_error_setting_mode">"Nepodarilo sa nastaviť režim, skúste to prosím znova."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Všetky správy"</string> <string name="screen_room_notification_settings_mode_all_messages">"Všetky správy"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Iba zmienky a kľúčové slová"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"V tejto miestnosti ma upozorniť na"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"V tejto miestnosti ma upozorniť na"</string>
<string name="screen_room_reactions_show_less">"Zobraziť menej"</string> <string name="screen_room_reactions_show_less">"Zobraziť menej"</string>
<string name="screen_room_reactions_show_more">"Zobraziť viac"</string> <string name="screen_room_reactions_show_more">"Zobraziť viac"</string>
@ -40,4 +40,5 @@
<string name="screen_room_timeline_less_reactions">"Zobraziť menej"</string> <string name="screen_room_timeline_less_reactions">"Zobraziť menej"</string>
<string name="screen_room_voice_message_tooltip">"Podržaním nahrajte"</string> <string name="screen_room_voice_message_tooltip">"Podržaním nahrajte"</string>
<string name="screen_room_error_failed_processing_media">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string> <string name="screen_room_error_failed_processing_media">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Iba zmienky a kľúčové slová"</string>
</resources> </resources>

View file

@ -19,11 +19,11 @@
<string name="screen_room_notification_settings_error_restoring_default">"無法重設為預設模式,請再試一次。"</string> <string name="screen_room_notification_settings_error_restoring_default">"無法重設為預設模式,請再試一次。"</string>
<string name="screen_room_notification_settings_error_setting_mode">"無法設定模式,請再試一次。"</string> <string name="screen_room_notification_settings_error_setting_mode">"無法設定模式,請再試一次。"</string>
<string name="screen_room_notification_settings_mode_all_messages">"所有訊息"</string> <string name="screen_room_notification_settings_mode_all_messages">"所有訊息"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"僅限提及與關鍵字"</string>
<string name="screen_room_reactions_show_less">"較少"</string> <string name="screen_room_reactions_show_less">"較少"</string>
<string name="screen_room_reactions_show_more">"更多"</string> <string name="screen_room_reactions_show_more">"更多"</string>
<string name="screen_room_retry_send_menu_send_again_action">"重傳"</string> <string name="screen_room_retry_send_menu_send_again_action">"重傳"</string>
<string name="screen_room_retry_send_menu_title">"無法傳送您的訊息"</string> <string name="screen_room_retry_send_menu_title">"無法傳送您的訊息"</string>
<string name="screen_room_timeline_add_reaction">"新增表情符號"</string> <string name="screen_room_timeline_add_reaction">"新增表情符號"</string>
<string name="screen_room_timeline_less_reactions">"較少"</string> <string name="screen_room_timeline_less_reactions">"較少"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"僅限提及與關鍵字"</string>
</resources> </resources>

View file

@ -12,7 +12,8 @@
<string name="screen_room_attachment_source_location">"Location"</string> <string name="screen_room_attachment_source_location">"Location"</string>
<string name="screen_room_attachment_source_poll">"Poll"</string> <string name="screen_room_attachment_source_poll">"Poll"</string>
<string name="screen_room_attachment_text_formatting">"Text Formatting"</string> <string name="screen_room_attachment_text_formatting">"Text Formatting"</string>
<string name="screen_room_encrypted_history_banner">"Message history is currently unavailable in this room"</string> <string name="screen_room_encrypted_history_banner">"Message history is currently unavailable."</string>
<string name="screen_room_encrypted_history_banner_unverified">"Message history is unavailable in this room. Verify this device to see your message history."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string> <string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string> <string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
<string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string> <string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string>
@ -29,7 +30,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Failed restoring the default mode, please try again."</string> <string name="screen_room_notification_settings_error_restoring_default">"Failed restoring the default mode, please try again."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Failed setting the mode, please try again."</string> <string name="screen_room_notification_settings_error_setting_mode">"Failed setting the mode, please try again."</string>
<string name="screen_room_notification_settings_mode_all_messages">"All messages"</string> <string name="screen_room_notification_settings_mode_all_messages">"All messages"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"In this room, notify me for"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"In this room, notify me for"</string>
<string name="screen_room_reactions_show_less">"Show less"</string> <string name="screen_room_reactions_show_less">"Show less"</string>
<string name="screen_room_reactions_show_more">"Show more"</string> <string name="screen_room_reactions_show_more">"Show more"</string>
@ -39,4 +39,5 @@
<string name="screen_room_timeline_less_reactions">"Show less"</string> <string name="screen_room_timeline_less_reactions">"Show less"</string>
<string name="screen_room_voice_message_tooltip">"Hold to record"</string> <string name="screen_room_voice_message_tooltip">"Hold to record"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string> <string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
</resources> </resources>

View file

@ -50,6 +50,7 @@ dependencies {
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.features.lockscreen.api)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api) implementation(projects.features.ftue.api)
implementation(projects.features.logout.api) implementation(projects.features.logout.api)

View file

@ -29,6 +29,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.preferences.impl.about.AboutNode import io.element.android.features.preferences.impl.about.AboutNode
import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNode import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNode
@ -51,6 +52,7 @@ import kotlinx.parcelize.Parcelize
class PreferencesFlowNode @AssistedInject constructor( class PreferencesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val lockScreenEntryPoint: LockScreenEntryPoint,
) : BackstackNode<PreferencesFlowNode.NavTarget>( ) : BackstackNode<PreferencesFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = plugins.filterIsInstance<PreferencesEntryPoint.Params>().first().initialElement.toNavTarget(), initialElement = plugins.filterIsInstance<PreferencesEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -82,6 +84,9 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object NotificationSettings : NavTarget data object NotificationSettings : NavTarget
@Parcelize
data object LockScreenSettings : NavTarget
@Parcelize @Parcelize
data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget
@ -117,6 +122,10 @@ class PreferencesFlowNode @AssistedInject constructor(
backstack.push(NavTarget.NotificationSettings) backstack.push(NavTarget.NotificationSettings)
} }
override fun onOpenLockScreenSettings() {
backstack.push(NavTarget.LockScreenSettings)
}
override fun onOpenAdvancedSettings() { override fun onOpenAdvancedSettings() {
backstack.push(NavTarget.AdvancedSettings) backstack.push(NavTarget.AdvancedSettings)
} }
@ -168,6 +177,11 @@ class PreferencesFlowNode @AssistedInject constructor(
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser) val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
createNode<EditUserProfileNode>(buildContext, listOf(inputs)) createNode<EditUserProfileNode>(buildContext, listOf(inputs))
} }
NavTarget.LockScreenSettings -> {
lockScreenEntryPoint.nodeBuilder(this, buildContext)
.target(LockScreenEntryPoint.Target.Settings)
.build()
}
} }
} }

View file

@ -47,6 +47,7 @@ class PreferencesRootNode @AssistedInject constructor(
fun onOpenAbout() fun onOpenAbout()
fun onOpenDeveloperSettings() fun onOpenDeveloperSettings()
fun onOpenNotificationSettings() fun onOpenNotificationSettings()
fun onOpenLockScreenSettings()
fun onOpenAdvancedSettings() fun onOpenAdvancedSettings()
fun onOpenUserProfile(matrixUser: MatrixUser) fun onOpenUserProfile(matrixUser: MatrixUser)
} }
@ -93,6 +94,10 @@ class PreferencesRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenNotificationSettings() } plugins<Callback>().forEach { it.onOpenNotificationSettings() }
} }
private fun onOpenLockScreenSettings() {
plugins<Callback>().forEach { it.onOpenLockScreenSettings() }
}
private fun onOpenUserProfile(matrixUser: MatrixUser) { private fun onOpenUserProfile(matrixUser: MatrixUser) {
plugins<Callback>().forEach { it.onOpenUserProfile(matrixUser) } plugins<Callback>().forEach { it.onOpenUserProfile(matrixUser) }
} }
@ -115,6 +120,7 @@ class PreferencesRootNode @AssistedInject constructor(
onSuccessLogout = { onSuccessLogout(activity, it) }, onSuccessLogout = { onSuccessLogout(activity, it) },
onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) }, onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) },
onOpenNotificationSettings = this::onOpenNotificationSettings, onOpenNotificationSettings = this::onOpenNotificationSettings,
onOpenLockScreenSettings = this::onOpenLockScreenSettings,
onOpenUserProfile = this::onOpenUserProfile, onOpenUserProfile = this::onOpenUserProfile,
) )
} }

View file

@ -68,6 +68,10 @@ class PreferencesRootPresenter @Inject constructor(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
showNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings) showNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
} }
val showLockScreenSettings = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
showLockScreenSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)
}
// We should display the 'complete verification' option if the current session can be verified // We should display the 'complete verification' option if the current session can be verified
val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false) val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false)
@ -95,6 +99,7 @@ class PreferencesRootPresenter @Inject constructor(
showAnalyticsSettings = hasAnalyticsProviders, showAnalyticsSettings = hasAnalyticsProviders,
showDeveloperSettings = showDeveloperSettings, showDeveloperSettings = showDeveloperSettings,
showNotificationSettings = showNotificationSettings.value, showNotificationSettings = showNotificationSettings.value,
showLockScreenSettings = showLockScreenSettings.value,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
) )
} }

View file

@ -29,6 +29,7 @@ data class PreferencesRootState(
val devicesManagementUrl: String?, val devicesManagementUrl: String?,
val showAnalyticsSettings: Boolean, val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean, val showDeveloperSettings: Boolean,
val showLockScreenSettings: Boolean,
val showNotificationSettings: Boolean, val showNotificationSettings: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
) )

View file

@ -30,5 +30,6 @@ fun aPreferencesRootState() = PreferencesRootState(
showAnalyticsSettings = true, showAnalyticsSettings = true,
showDeveloperSettings = true, showDeveloperSettings = true,
showNotificationSettings = true, showNotificationSettings = true,
showLockScreenSettings = true,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
) )

View file

@ -20,6 +20,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.outlined.InsertChart import androidx.compose.material.icons.outlined.InsertChart
import androidx.compose.material.icons.outlined.VerifiedUser import androidx.compose.material.icons.outlined.VerifiedUser
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -53,6 +54,7 @@ fun PreferencesRootView(
onManageAccountClicked: (url: String) -> Unit, onManageAccountClicked: (url: String) -> Unit,
onOpenAnalytics: () -> Unit, onOpenAnalytics: () -> Unit,
onOpenRageShake: () -> Unit, onOpenRageShake: () -> Unit,
onOpenLockScreenSettings: ()->Unit,
onOpenAbout: () -> Unit, onOpenAbout: () -> Unit,
onOpenDeveloperSettings: () -> Unit, onOpenDeveloperSettings: () -> Unit,
onOpenAdvancedSettings: () -> Unit, onOpenAdvancedSettings: () -> Unit,
@ -116,6 +118,13 @@ fun PreferencesRootView(
iconResourceId = CommonDrawables.ic_compound_info, iconResourceId = CommonDrawables.ic_compound_info,
onClick = onOpenAbout, onClick = onOpenAbout,
) )
if (state.showLockScreenSettings) {
PreferenceText(
title = stringResource(id = CommonStrings.common_screen_lock),
icon = Icons.Default.Lock,
onClick = onOpenLockScreenSettings,
)
}
HorizontalDivider() HorizontalDivider()
if (state.devicesManagementUrl != null) { if (state.devicesManagementUrl != null) {
PreferenceText( PreferenceText(
@ -183,6 +192,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onSuccessLogout = {}, onSuccessLogout = {},
onManageAccountClicked = {}, onManageAccountClicked = {},
onOpenNotificationSettings = {}, onOpenNotificationSettings = {},
onOpenLockScreenSettings = {},
onOpenUserProfile = {}, onOpenUserProfile = {},
) )
} }

View file

@ -36,7 +36,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu."</string> <string name="screen_room_notification_settings_error_restoring_default">"Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Nastavení režimu se nezdařilo, zkuste to prosím znovu."</string> <string name="screen_room_notification_settings_error_setting_mode">"Nastavení režimu se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Všechny zprávy"</string> <string name="screen_room_notification_settings_mode_all_messages">"Všechny zprávy"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Pouze zmínky a klíčová slova"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"V této místnosti mě upozornit na"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"V této místnosti mě upozornit na"</string>
<string name="screen_dm_details_block_alert_action">"Zablokovat"</string> <string name="screen_dm_details_block_alert_action">"Zablokovat"</string>
<string name="screen_dm_details_block_alert_description">"Blokovaní uživatelé vám nebudou moci posílat zprávy a všechny jejich zprávy budou skryty. Můžete je kdykoli odblokovat."</string> <string name="screen_dm_details_block_alert_description">"Blokovaní uživatelé vám nebudou moci posílat zprávy a všechny jejich zprávy budou skryty. Můžete je kdykoli odblokovat."</string>
@ -47,4 +46,5 @@
<string name="screen_room_details_leave_room_title">"Opustit místnost"</string> <string name="screen_room_details_leave_room_title">"Opustit místnost"</string>
<string name="screen_room_details_security_title">"Zabezpečení"</string> <string name="screen_room_details_security_title">"Zabezpečení"</string>
<string name="screen_room_details_topic_title">"Téma"</string> <string name="screen_room_details_topic_title">"Téma"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Pouze zmínky a klíčová slova"</string>
</resources> </resources>

View file

@ -35,7 +35,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Fehler beim Wiederherstellen des Standardmodus. Bitte versuche es erneut."</string> <string name="screen_room_notification_settings_error_restoring_default">"Fehler beim Wiederherstellen des Standardmodus. Bitte versuche es erneut."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Fehler beim Einstellen des Modus. Bitte versuche es erneut."</string> <string name="screen_room_notification_settings_error_setting_mode">"Fehler beim Einstellen des Modus. Bitte versuche es erneut."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Alle Nachrichten"</string> <string name="screen_room_notification_settings_mode_all_messages">"Alle Nachrichten"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Nur Erwähnungen und Schlüsselwörter"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Benachrichtige mich in diesem Raum bei"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"Benachrichtige mich in diesem Raum bei"</string>
<string name="screen_dm_details_block_alert_action">"Sperren"</string> <string name="screen_dm_details_block_alert_action">"Sperren"</string>
<string name="screen_dm_details_block_alert_description">"Gesperrte Benutzer können dir keine Nachrichten senden und alle ihre Nachrichten werden ausgeblendet. Du kannst sie jederzeit entsperren."</string> <string name="screen_dm_details_block_alert_description">"Gesperrte Benutzer können dir keine Nachrichten senden und alle ihre Nachrichten werden ausgeblendet. Du kannst sie jederzeit entsperren."</string>
@ -46,4 +45,5 @@
<string name="screen_room_details_leave_room_title">"Raum verlassen"</string> <string name="screen_room_details_leave_room_title">"Raum verlassen"</string>
<string name="screen_room_details_security_title">"Sicherheit"</string> <string name="screen_room_details_security_title">"Sicherheit"</string>
<string name="screen_room_details_topic_title">"Thema"</string> <string name="screen_room_details_topic_title">"Thema"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Nur Erwähnungen und Schlüsselwörter"</string>
</resources> </resources>

View file

@ -35,7 +35,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Échec de la restauration du mode par défaut, veuillez réessayer."</string> <string name="screen_room_notification_settings_error_restoring_default">"Échec de la restauration du mode par défaut, veuillez réessayer."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Échec de la configuration du mode, veuillez réessayer."</string> <string name="screen_room_notification_settings_error_setting_mode">"Échec de la configuration du mode, veuillez réessayer."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Tous les messages"</string> <string name="screen_room_notification_settings_mode_all_messages">"Tous les messages"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions et mots clés uniquement"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Dans ce salon, prévenez-moi pour"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"Dans ce salon, prévenez-moi pour"</string>
<string name="screen_dm_details_block_alert_action">"Bloquer"</string> <string name="screen_dm_details_block_alert_action">"Bloquer"</string>
<string name="screen_dm_details_block_alert_description">"Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez les débloquer à tout moment."</string> <string name="screen_dm_details_block_alert_description">"Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez les débloquer à tout moment."</string>
@ -46,4 +45,5 @@
<string name="screen_room_details_leave_room_title">"Quitter le salon"</string> <string name="screen_room_details_leave_room_title">"Quitter le salon"</string>
<string name="screen_room_details_security_title">"Sécurité"</string> <string name="screen_room_details_security_title">"Sécurité"</string>
<string name="screen_room_details_topic_title">"Sujet"</string> <string name="screen_room_details_topic_title">"Sujet"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions et mots clés uniquement"</string>
</resources> </resources>

View file

@ -35,7 +35,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Nu s-a reușit restaurarea modului implicit, vă rugăm să încercați din nou."</string> <string name="screen_room_notification_settings_error_restoring_default">"Nu s-a reușit restaurarea modului implicit, vă rugăm să încercați din nou."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Nu s-a reușit setarea modului, vă rugăm să încercați din nou."</string> <string name="screen_room_notification_settings_error_setting_mode">"Nu s-a reușit setarea modului, vă rugăm să încercați din nou."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Toate mesajele"</string> <string name="screen_room_notification_settings_mode_all_messages">"Toate mesajele"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Numai mențiuni și cuvinte cheie"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"În această cameră, anunțați-mă pentru"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"În această cameră, anunțați-mă pentru"</string>
<string name="screen_dm_details_block_alert_action">"Blocați"</string> <string name="screen_dm_details_block_alert_action">"Blocați"</string>
<string name="screen_dm_details_block_alert_description">"Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."</string> <string name="screen_dm_details_block_alert_description">"Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."</string>
@ -46,4 +45,5 @@
<string name="screen_room_details_leave_room_title">"Părăsiți camera"</string> <string name="screen_room_details_leave_room_title">"Părăsiți camera"</string>
<string name="screen_room_details_security_title">"Securitate"</string> <string name="screen_room_details_security_title">"Securitate"</string>
<string name="screen_room_details_topic_title">"Subiect"</string> <string name="screen_room_details_topic_title">"Subiect"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Numai mențiuni și cuvinte cheie"</string>
</resources> </resources>

View file

@ -36,7 +36,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Не удалось восстановить режим по умолчанию, попробуйте еще раз."</string> <string name="screen_room_notification_settings_error_restoring_default">"Не удалось восстановить режим по умолчанию, попробуйте еще раз."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Не удалось настроить режим, попробуйте еще раз."</string> <string name="screen_room_notification_settings_error_setting_mode">"Не удалось настроить режим, попробуйте еще раз."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Все сообщения"</string> <string name="screen_room_notification_settings_mode_all_messages">"Все сообщения"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Только упоминания и ключевые слова"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"В этой комнате уведомить меня о"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"В этой комнате уведомить меня о"</string>
<string name="screen_dm_details_block_alert_action">"Заблокировать"</string> <string name="screen_dm_details_block_alert_action">"Заблокировать"</string>
<string name="screen_dm_details_block_alert_description">"Заблокированные пользователи не смогут отправлять вам сообщения, а все их сообщения будут скрыты. Вы можете разблокировать их в любое время."</string> <string name="screen_dm_details_block_alert_description">"Заблокированные пользователи не смогут отправлять вам сообщения, а все их сообщения будут скрыты. Вы можете разблокировать их в любое время."</string>
@ -47,4 +46,5 @@
<string name="screen_room_details_leave_room_title">"Покинуть комнату"</string> <string name="screen_room_details_leave_room_title">"Покинуть комнату"</string>
<string name="screen_room_details_security_title">"Безопасность"</string> <string name="screen_room_details_security_title">"Безопасность"</string>
<string name="screen_room_details_topic_title">"Тема"</string> <string name="screen_room_details_topic_title">"Тема"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Только упоминания и ключевые слова"</string>
</resources> </resources>

View file

@ -36,7 +36,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Nepodarilo sa obnoviť predvolený režim, skúste to prosím znova."</string> <string name="screen_room_notification_settings_error_restoring_default">"Nepodarilo sa obnoviť predvolený režim, skúste to prosím znova."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Nepodarilo sa nastaviť režim, skúste to prosím znova."</string> <string name="screen_room_notification_settings_error_setting_mode">"Nepodarilo sa nastaviť režim, skúste to prosím znova."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Všetky správy"</string> <string name="screen_room_notification_settings_mode_all_messages">"Všetky správy"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Iba zmienky a kľúčové slová"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"V tejto miestnosti ma upozorniť na"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"V tejto miestnosti ma upozorniť na"</string>
<string name="screen_dm_details_block_alert_action">"Zablokovať"</string> <string name="screen_dm_details_block_alert_action">"Zablokovať"</string>
<string name="screen_dm_details_block_alert_description">"Blokovaní používatelia vám nebudú môcť posielať správy a všetky ich správy budú skryté. Môžete ich kedykoľvek odblokovať."</string> <string name="screen_dm_details_block_alert_description">"Blokovaní používatelia vám nebudú môcť posielať správy a všetky ich správy budú skryté. Môžete ich kedykoľvek odblokovať."</string>
@ -47,4 +46,5 @@
<string name="screen_room_details_leave_room_title">"Opustiť miestnosť"</string> <string name="screen_room_details_leave_room_title">"Opustiť miestnosť"</string>
<string name="screen_room_details_security_title">"Bezpečnosť"</string> <string name="screen_room_details_security_title">"Bezpečnosť"</string>
<string name="screen_room_details_topic_title">"Téma"</string> <string name="screen_room_details_topic_title">"Téma"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Iba zmienky a kľúčové slová"</string>
</resources> </resources>

View file

@ -26,7 +26,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"無法重設為預設模式,請再試一次。"</string> <string name="screen_room_notification_settings_error_restoring_default">"無法重設為預設模式,請再試一次。"</string>
<string name="screen_room_notification_settings_error_setting_mode">"無法設定模式,請再試一次。"</string> <string name="screen_room_notification_settings_error_setting_mode">"無法設定模式,請再試一次。"</string>
<string name="screen_room_notification_settings_mode_all_messages">"所有訊息"</string> <string name="screen_room_notification_settings_mode_all_messages">"所有訊息"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"僅限提及與關鍵字"</string>
<string name="screen_dm_details_block_alert_action">"封鎖"</string> <string name="screen_dm_details_block_alert_action">"封鎖"</string>
<string name="screen_dm_details_block_user">"封鎖使用者"</string> <string name="screen_dm_details_block_user">"封鎖使用者"</string>
<string name="screen_dm_details_unblock_alert_action">"解除封鎖"</string> <string name="screen_dm_details_unblock_alert_action">"解除封鎖"</string>
@ -34,4 +33,5 @@
<string name="screen_room_details_leave_room_title">"離開聊天室"</string> <string name="screen_room_details_leave_room_title">"離開聊天室"</string>
<string name="screen_room_details_security_title">"安全性"</string> <string name="screen_room_details_security_title">"安全性"</string>
<string name="screen_room_details_topic_title">"主題"</string> <string name="screen_room_details_topic_title">"主題"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"僅限提及與關鍵字"</string>
</resources> </resources>

View file

@ -35,7 +35,6 @@
<string name="screen_room_notification_settings_error_restoring_default">"Failed restoring the default mode, please try again."</string> <string name="screen_room_notification_settings_error_restoring_default">"Failed restoring the default mode, please try again."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Failed setting the mode, please try again."</string> <string name="screen_room_notification_settings_error_setting_mode">"Failed setting the mode, please try again."</string>
<string name="screen_room_notification_settings_mode_all_messages">"All messages"</string> <string name="screen_room_notification_settings_mode_all_messages">"All messages"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"In this room, notify me for"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"In this room, notify me for"</string>
<string name="screen_dm_details_block_alert_action">"Block"</string> <string name="screen_dm_details_block_alert_action">"Block"</string>
<string name="screen_dm_details_block_alert_description">"Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime."</string> <string name="screen_dm_details_block_alert_description">"Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime."</string>
@ -46,4 +45,5 @@
<string name="screen_room_details_leave_room_title">"Leave room"</string> <string name="screen_room_details_leave_room_title">"Leave room"</string>
<string name="screen_room_details_security_title">"Security"</string> <string name="screen_room_details_security_title">"Security"</string>
<string name="screen_room_details_topic_title">"Topic"</string> <string name="screen_room_details_topic_title">"Topic"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
</resources> </resources>

View file

@ -32,7 +32,7 @@ dependencies {
implementation(libs.dagger) implementation(libs.dagger)
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(projects.libraries.cryptography.api) api(projects.libraries.cryptography.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)

View file

@ -40,7 +40,9 @@ class KeyStoreSecretKeyProvider @Inject constructor() : SecretKeyProvider {
// False positive lint issue // False positive lint issue
@SuppressLint("WrongConstant") @SuppressLint("WrongConstant")
override fun getOrCreateKey(alias: String): SecretKey { override fun getOrCreateKey(alias: String): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply {
load(null)
}
val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry) val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)
?.secretKey ?.secretKey
return if (secretKeyEntry == null) { return if (secretKeyEntry == null) {

View file

@ -30,6 +30,7 @@
<string name="state_event_room_remove_by_you">"%1$s 已被您移除"</string> <string name="state_event_room_remove_by_you">"%1$s 已被您移除"</string>
<string name="state_event_room_third_party_invite">"%1$s 邀請 %2$s 加入聊天室"</string> <string name="state_event_room_third_party_invite">"%1$s 邀請 %2$s 加入聊天室"</string>
<string name="state_event_room_third_party_invite_by_you">"您邀請 %1$s 加入聊天室"</string> <string name="state_event_room_third_party_invite_by_you">"您邀請 %1$s 加入聊天室"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s 撤銷了 %2$s 加入房間的邀請"</string>
<string name="state_event_room_topic_changed">"%1$s 將主題變更為 %2$s"</string> <string name="state_event_room_topic_changed">"%1$s 將主題變更為 %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"您將主題變更為 %1$s"</string> <string name="state_event_room_topic_changed_by_you">"您將主題變更為 %1$s"</string>
<string name="state_event_room_topic_removed">"聊天室主題已被 %1$s 移除"</string> <string name="state_event_room_topic_removed">"聊天室主題已被 %1$s 移除"</string>

View file

@ -167,6 +167,7 @@
<string name="common_video">"Video"</string> <string name="common_video">"Video"</string>
<string name="common_voice_message">"Hlasová správa"</string> <string name="common_voice_message">"Hlasová správa"</string>
<string name="common_waiting">"Čaká sa…"</string> <string name="common_waiting">"Čaká sa…"</string>
<string name="common_waiting_for_decryption_key">"Čaká sa na dešifrovací kľúč"</string>
<string name="common_poll_end_confirmation">"Ste si istí, že chcete ukončiť túto anketu?"</string> <string name="common_poll_end_confirmation">"Ste si istí, že chcete ukončiť túto anketu?"</string>
<string name="common_poll_summary">"Anketa: %1$s"</string> <string name="common_poll_summary">"Anketa: %1$s"</string>
<string name="dialog_title_confirmation">"Potvrdenie"</string> <string name="dialog_title_confirmation">"Potvrdenie"</string>
@ -273,6 +274,8 @@ Ak budete pokračovať, niektoré z vašich nastavení sa môžu zmeniť."</stri
<string name="screen_recovery_key_confirm_key_placeholder">"Zadať…"</string> <string name="screen_recovery_key_confirm_key_placeholder">"Zadať…"</string>
<string name="screen_recovery_key_confirm_success">"Kľúč na obnovu potvrdený"</string> <string name="screen_recovery_key_confirm_success">"Kľúč na obnovu potvrdený"</string>
<string name="screen_recovery_key_confirm_title">"Potvrďte kľúč na obnovenie"</string> <string name="screen_recovery_key_confirm_title">"Potvrďte kľúč na obnovenie"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Skopírovaný kľúč na obnovenie"</string>
<string name="screen_recovery_key_generating_key">"Generovanie…"</string>
<string name="screen_recovery_key_save_action">"Uložiť kľúč na obnovenie"</string> <string name="screen_recovery_key_save_action">"Uložiť kľúč na obnovenie"</string>
<string name="screen_recovery_key_save_description">"Zapíšte si kľúč na obnovenie na bezpečné miesto alebo ho uložte do správcu hesiel."</string> <string name="screen_recovery_key_save_description">"Zapíšte si kľúč na obnovenie na bezpečné miesto alebo ho uložte do správcu hesiel."</string>
<string name="screen_recovery_key_save_key_description">"Ťuknutím skopírujte kľúč na obnovenie"</string> <string name="screen_recovery_key_save_key_description">"Ťuknutím skopírujte kľúč na obnovenie"</string>

View file

@ -35,6 +35,7 @@
<string name="action_edit">"Edit"</string> <string name="action_edit">"Edit"</string>
<string name="action_enable">"Enable"</string> <string name="action_enable">"Enable"</string>
<string name="action_end_poll">"End poll"</string> <string name="action_end_poll">"End poll"</string>
<string name="action_enter_pin">"Enter PIN"</string>
<string name="action_forgot_password">"Forgot password?"</string> <string name="action_forgot_password">"Forgot password?"</string>
<string name="action_forward">"Forward"</string> <string name="action_forward">"Forward"</string>
<string name="action_invite">"Invite"</string> <string name="action_invite">"Invite"</string>
@ -52,7 +53,7 @@
<string name="action_no">"No"</string> <string name="action_no">"No"</string>
<string name="action_not_now">"Not now"</string> <string name="action_not_now">"Not now"</string>
<string name="action_ok">"OK"</string> <string name="action_ok">"OK"</string>
<string name="action_open_settings">"Open settings"</string> <string name="action_open_settings">"Settings"</string>
<string name="action_open_with">"Open with"</string> <string name="action_open_with">"Open with"</string>
<string name="action_quick_reply">"Quick reply"</string> <string name="action_quick_reply">"Quick reply"</string>
<string name="action_quote">"Quote"</string> <string name="action_quote">"Quote"</string>
@ -146,6 +147,7 @@
<string name="common_server_url">"Server URL"</string> <string name="common_server_url">"Server URL"</string>
<string name="common_settings">"Settings"</string> <string name="common_settings">"Settings"</string>
<string name="common_shared_location">"Shared location"</string> <string name="common_shared_location">"Shared location"</string>
<string name="common_signing_out">"Signing out"</string>
<string name="common_starting_chat">"Starting chat…"</string> <string name="common_starting_chat">"Starting chat…"</string>
<string name="common_sticker">"Sticker"</string> <string name="common_sticker">"Sticker"</string>
<string name="common_success">"Success"</string> <string name="common_success">"Success"</string>
@ -168,8 +170,11 @@
<string name="common_video">"Video"</string> <string name="common_video">"Video"</string>
<string name="common_voice_message">"Voice message"</string> <string name="common_voice_message">"Voice message"</string>
<string name="common_waiting">"Waiting…"</string> <string name="common_waiting">"Waiting…"</string>
<string name="common_waiting_for_decryption_key">"Waiting for decryption key"</string>
<string name="common_poll_end_confirmation">"Are you sure you want to end this poll?"</string> <string name="common_poll_end_confirmation">"Are you sure you want to end this poll?"</string>
<string name="common_poll_summary">"Poll: %1$s"</string> <string name="common_poll_summary">"Poll: %1$s"</string>
<string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to confirm your recovery key to maintain access to your chat backup."</string>
<string name="confirm_recovery_key_banner_title">"Confirm your recovery key"</string>
<string name="dialog_title_confirmation">"Confirmation"</string> <string name="dialog_title_confirmation">"Confirmation"</string>
<string name="dialog_title_warning">"Warning"</string> <string name="dialog_title_warning">"Warning"</string>
<string name="emoji_picker_category_activity">"Activities"</string> <string name="emoji_picker_category_activity">"Activities"</string>
@ -269,6 +274,8 @@ If you proceed, some of your settings may change."</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Enter…"</string> <string name="screen_recovery_key_confirm_key_placeholder">"Enter…"</string>
<string name="screen_recovery_key_confirm_success">"Recovery key confirmed"</string> <string name="screen_recovery_key_confirm_success">"Recovery key confirmed"</string>
<string name="screen_recovery_key_confirm_title">"Confirm your recovery key"</string> <string name="screen_recovery_key_confirm_title">"Confirm your recovery key"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Copied recovery key"</string>
<string name="screen_recovery_key_generating_key">"Generating…"</string>
<string name="screen_recovery_key_save_action">"Save recovery key"</string> <string name="screen_recovery_key_save_action">"Save recovery key"</string>
<string name="screen_recovery_key_save_description">"Write down your recovery key somewhere safe or save it in a password manager."</string> <string name="screen_recovery_key_save_description">"Write down your recovery key somewhere safe or save it in a password manager."</string>
<string name="screen_recovery_key_save_key_description">"Tap to copy recovery key"</string> <string name="screen_recovery_key_save_key_description">"Tap to copy recovery key"</string>

View file

@ -101,6 +101,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:mediaupload:impl")) implementation(project(":libraries:mediaupload:impl"))
implementation(project(":libraries:usersearch:impl")) implementation(project(":libraries:usersearch:impl"))
implementation(project(":libraries:textcomposer:impl")) implementation(project(":libraries:textcomposer:impl"))
implementation(project(":libraries:cryptography:impl"))
implementation(project(":libraries:voicerecorder:impl")) implementation(project(":libraries:voicerecorder:impl"))
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:de1d44f62edd3f2d30421e49ad435971b165711b8bfe6d4a9475c5fb2f9f83ed oid sha256:079fff99063ebce93c14b1c373ead6eba3a5952610c3c43429da597ae3a804b1
size 8506 size 11928

Some files were not shown because too many files have changed in this diff Show more