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
150 changed files with 1858 additions and 461 deletions

View File

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

View File

@@ -16,9 +16,20 @@
plugins {
id("java-library")
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
}
java {
sourceCompatibility = 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
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.
*/
const val IS_PIN_MANDATORY: Boolean = false
val isPinMandatory: Boolean,
/**
* Some PINs are blacklisted.
*/
val PIN_BLACKLIST = setOf("0000", "1234")
val pinBlacklist: Set<String>,
/**
* 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.state.FtueState
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.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.roomlist.api.RoomListEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
@@ -94,7 +94,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val notificationDrawerManager: NotificationDrawerManager,
private val ftueState: FtueState,
private val lockScreenEntryPoint: LockScreenEntryPoint,
private val lockScreenStateService: LockScreenStateService,
private val lockScreenStateService: LockScreenService,
private val matrixClient: MatrixClient,
snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>(
@@ -134,16 +134,6 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.Ftue)
}
},
onResume = {
coroutineScope.launch {
lockScreenStateService.entersForeground()
}
},
onPause = {
coroutineScope.launch {
lockScreenStateService.entersBackground()
}
},
onStop = {
coroutineScope.launch {
//Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
@@ -220,7 +210,9 @@ class LoggedInFlowNode @AssistedInject constructor(
createNode<LoggedInNode>(buildContext)
}
NavTarget.LockPermanent -> {
lockScreenEntryPoint.createNode(this, buildContext)
lockScreenEntryPoint.nodeBuilder(this, buildContext)
.target(LockScreenEntryPoint.Target.Unlock)
.build()
}
NavTarget.RoomList -> {
val callback = object : RoomListEntryPoint.Callback {
@@ -357,9 +349,9 @@ class LoggedInFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {
val lockScreenState by lockScreenStateService.state.collectAsState()
val lockScreenState by lockScreenStateService.lockState.collectAsState()
when (lockScreenState) {
LockScreenState.Unlocked -> {
LockScreenLockState.Unlocked -> {
Children(
navModel = backstack,
modifier = Modifier,
@@ -371,7 +363,7 @@ class LoggedInFlowNode @AssistedInject constructor(
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
}
}
LockScreenState.Locked -> {
LockScreenLockState.Locked -> {
MoveActivityToBackgroundBackHandler()
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockPermanent)
}

View File

@@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api)
implementation(projects.services.analytics.api)
implementation(projects.features.lockscreen.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(projects.services.toolbox.api)
@@ -57,6 +58,7 @@ dependencies {
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.tests.testutils)
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.FtueStep
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.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
@@ -60,11 +61,12 @@ class FtueFlowNode @AssistedInject constructor(
private val ftueState: DefaultFtueState,
private val analyticsEntryPoint: AnalyticsEntryPoint,
private val analyticsService: AnalyticsService,
private val lockScreenEntryPoint: LockScreenEntryPoint,
) : BackstackNode<FtueFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap,
backPressHandler = NoOpBackstackHandlerStrategy<NavTarget>(),
backPressHandler = NoOpBackstackHandlerStrategy(),
),
buildContext = buildContext,
plugins = plugins,
@@ -85,6 +87,9 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize
data object AnalyticsOptIn : NavTarget
@Parcelize
data object LockScreenSetup : NavTarget
}
private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull()
@@ -139,6 +144,17 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.AnalyticsOptIn -> {
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 -> {
backstack.replace(NavTarget.AnalyticsOptIn)
}
FtueStep.LockscreenSetup -> {
backstack.newRoot(NavTarget.LockScreenSetup)
}
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.impl.migration.MigrationScreenStore
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.matrix.api.MatrixClient
import io.element.android.libraries.permissions.api.PermissionStateProvider
@@ -44,6 +45,7 @@ class DefaultFtueState @Inject constructor(
private val welcomeScreenState: WelcomeScreenState,
private val migrationScreenStore: MigrationScreenStore,
private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService,
private val matrixClient: MatrixClient,
) : FtueState {
@@ -72,10 +74,13 @@ class DefaultFtueState @Inject constructor(
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
FtueStep.WelcomeScreen
)
FtueStep.WelcomeScreen -> if (shouldAskNotificationPermissions()) FtueStep.NotificationsOptIn else getNextStep(
FtueStep.WelcomeScreen -> if (shouldAskNotificationPermissions()) FtueStep.NotificationsOptIn else getNextStep(
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 -> null
@@ -83,11 +88,12 @@ class DefaultFtueState @Inject constructor(
private fun isAnyStepIncomplete(): Boolean {
return listOf(
shouldDisplayMigrationScreen(),
shouldDisplayWelcomeScreen(),
shouldAskNotificationPermissions(),
needsAnalyticsOptIn()
).any { it }
{ shouldDisplayMigrationScreen() },
{ shouldDisplayWelcomeScreen() },
{ shouldAskNotificationPermissions() },
{ needsAnalyticsOptIn() },
{ shouldDisplayLockscreenSetup() },
).any { it() }
}
private fun shouldDisplayMigrationScreen(): Boolean {
@@ -112,6 +118,12 @@ class DefaultFtueState @Inject constructor(
} else false
}
private fun shouldDisplayLockscreenSetup(): Boolean {
return runBlocking {
lockScreenService.isSetupRequired()
}
}
fun setWelcomeScreenShown() {
welcomeScreenState.setWelcomeScreenShown()
updateState()
@@ -128,4 +140,5 @@ sealed interface FtueStep {
data object WelcomeScreen : FtueStep
data object NotificationsOptIn : 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.FtueStep
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.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -186,6 +188,7 @@ class DefaultFtueStateTests {
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
matrixClient: MatrixClient = FakeMatrixClient(),
lockScreenService: LockScreenService = FakeLockScreenService(),
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, // First version where notification permission is required
) = DefaultFtueState(
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
@@ -194,6 +197,7 @@ class DefaultFtueStateTests {
welcomeScreenState = welcomeState,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
matrixClient = matrixClient,
)
}

View File

@@ -16,6 +16,28 @@
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
sealed interface LockScreenState {
data object Unlocked : LockScreenState
data object Locked : LockScreenState
sealed interface LockScreenLockState {
data object Unlocked : LockScreenLockState
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.cryptography.api)
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.coroutines.test)
@@ -54,4 +57,6 @@ dependencies {
testImplementation(projects.libraries.cryptography.test)
testImplementation(projects.libraries.cryptography.impl)
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)
class DefaultLockScreenEntryPoint @Inject constructor() : LockScreenEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<LockScreenFlowNode>(buildContext)
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LockScreenEntryPoint.NodeBuilder {
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.ui.Modifier
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.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
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.unlock.PinUnlockNode
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.createNode
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@ContributesNode(SessionScope::class)
class LockScreenFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager,
) : BackstackNode<LockScreenFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Unlock,
initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialNavTarget,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
data class Inputs(
val initialNavTarget: NavTarget = NavTarget.Unlock,
) : NodeInputs
sealed interface NavTarget : Parcelable {
@Parcelize
data object Unlock : NavTarget
@Parcelize
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 {
@@ -64,6 +99,9 @@ class LockScreenFlowNode @AssistedInject constructor(
NavTarget.Setup -> {
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.layout.Arrangement
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.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
@@ -30,7 +33,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
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.PinEntry
@@ -42,34 +44,37 @@ import io.element.android.libraries.theme.ElementTheme
@Composable
fun PinEntryTextField(
pinEntry: PinEntry,
isSecured: Boolean,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
BasicTextField(
modifier = modifier,
value = TextFieldValue(pinEntry.toText()),
value = pinEntry.toText(),
onValueChange = {
onValueChange(it.text)
onValueChange(it)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
decorationBox = {
PinEntryRow(pinEntry = pinEntry)
PinEntryRow(pinEntry = pinEntry, isSecured = isSecured)
}
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun PinEntryRow(
pinEntry: PinEntry,
isSecured: Boolean,
modifier: Modifier = Modifier,
) {
Row(
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
for (digit in pinEntry.digits) {
PinDigitView(digit = digit)
PinDigitView(digit = digit, isSecured = isSecured)
}
}
}
@@ -77,6 +82,7 @@ private fun PinEntryRow(
@Composable
private fun PinDigitView(
digit: PinDigit,
isSecured: Boolean,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(8.dp)
@@ -96,8 +102,13 @@ private fun PinDigitView(
) {
if (digit is PinDigit.Filled) {
val text = if (isSecured) {
""
} else {
digit.value.toString()
}
Text(
text = digit.toText(),
text = text,
style = ElementTheme.typography.fontHeadingMdBold
)
}
@@ -109,9 +120,19 @@ private fun PinDigitView(
@Composable
internal fun PinEntryTextFieldPreview() {
ElementPreview {
PinEntryTextField(
pinEntry = PinEntry.createEmpty(4).fillWith("12"),
onValueChange = {},
)
val pinEntry = PinEntry.createEmpty(4).fillWith("12")
Column {
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.SecretKeyProvider
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import java.util.concurrent.CopyOnWriteArrayList
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)
@SingleIn(AppScope::class)
class DefaultPinCodeManager @Inject constructor(
private val secretKeyProvider: SecretKeyProvider,
private val encryptionDecryptionService: EncryptionDecryptionService,
private val pinCodeStore: PinCodeStore,
) : 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 {
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) {
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
val encryptedPinCode = encryptionDecryptionService.encrypt(secretKey, pinCode.toByteArray()).toBase64()
pinCodeStore.saveEncryptedPinCode(encryptedPinCode)
callbacks.forEach { it.onPinCodeCreated() }
}
override suspend fun verifyPinCode(pinCode: String): Boolean {
@@ -48,7 +69,17 @@ class DefaultPinCodeManager @Inject constructor(
return try {
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
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) {
false
}
@@ -56,17 +87,11 @@ class DefaultPinCodeManager @Inject constructor(
override suspend fun deletePinCode() {
pinCodeStore.deleteEncryptedPinCode()
pinCodeStore.resetCounter()
callbacks.forEach { it.onPinCodeRemoved() }
}
override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
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.
*/
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 {
val state: StateFlow<LockScreenState>
override fun onPinCodeCreated() = Unit
suspend fun entersForeground()
suspend fun entersBackground()
suspend fun unlock()
override fun onPinCodeRemoved() = Unit
}

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.
*/
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.
*/
suspend fun isPinCodeAvailable(): Boolean
/**
* @return the size of the saved pin code.
*/
suspend fun getPinCodeSize(): Int
/**
* Creates a new encrypted pin code.
* @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.
*/
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
*/
fun fillWith(text: String): PinEntry {
val newDigits = digits.toMutableList()
val newDigits = MutableList<PinDigit>(size) { PinDigit.Empty }
text.forEachIndexed { index, char ->
if (index < size) {
if (index < size && char.isDigit()) {
newDigits[index] = PinDigit.Filled(char)
}
}

View File

@@ -18,10 +18,6 @@ package io.element.android.features.lockscreen.impl.pin.storage
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.
*/
@@ -29,24 +25,13 @@ interface PinCodeStore : EncryptedPinCodeStorage {
/**
* 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.
*/
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.AssistedInject
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(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@@ -38,7 +38,7 @@ class SetupPinNode @AssistedInject constructor(
val state = presenter.present()
SetupPinView(
state = state,
onBackClicked = { },
onBackClicked = this::navigateUp,
modifier = modifier
)
}

View File

@@ -17,30 +17,35 @@
package io.element.android.features.lockscreen.impl.setup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.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.SetupPinFailure
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.coroutines.delay
import javax.inject.Inject
class SetupPinPresenter @Inject constructor(
private val lockScreenConfig: LockScreenConfig,
private val pinValidator: PinValidator,
private val buildMeta: BuildMeta,
private val pinCodeManager: PinCodeManager,
) : Presenter<SetupPinState> {
@Composable
override fun present(): SetupPinState {
var choosePinEntry by remember {
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE))
mutableStateOf(PinEntry.createEmpty(lockScreenConfig.pinSize))
}
var confirmPinEntry by remember {
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE))
mutableStateOf(PinEntry.createEmpty(lockScreenConfig.pinSize))
}
var isConfirmationStep by remember {
mutableStateOf(false)
@@ -48,29 +53,38 @@ class SetupPinPresenter @Inject constructor(
var setupPinFailure by remember {
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) {
when (event) {
is SetupPinEvents.OnPinEntryChanged -> {
if (isConfirmationStep) {
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 {
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 -> {

View File

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

View File

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

View File

@@ -17,35 +17,39 @@
package io.element.android.features.lockscreen.impl.unlock
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.api.LockScreenStateService
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.unlock.keypad.PinKeypadModel
import io.element.android.libraries.architecture.Async
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.launch
import javax.inject.Inject
class PinUnlockPresenter @Inject constructor(
private val pinStateService: LockScreenStateService,
private val pinCodeManager: PinCodeManager,
private val matrixClient: MatrixClient,
private val coroutineScope: CoroutineScope,
) : Presenter<PinUnlockState> {
@Composable
override fun present(): PinUnlockState {
var pinEntry by remember {
//TODO fetch size from db
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE))
val pinEntryState = remember {
mutableStateOf<Async<PinEntry>>(Async.Uninitialized)
}
var remainingAttempts by rememberSaveable {
//TODO fetch from db
mutableIntStateOf(3)
val pinEntry by pinEntryState
var remainingAttempts by remember {
mutableStateOf<Async<Int>>(Async.Uninitialized)
}
var showWrongPinTitle by rememberSaveable {
mutableStateOf(false)
@@ -53,18 +57,48 @@ class PinUnlockPresenter @Inject constructor(
var showSignOutPrompt by rememberSaveable {
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) {
when (event) {
is PinUnlockEvents.OnPinKeypadPressed -> {
pinEntry = pinEntry.process(event.pinKeypadModel)
if (pinEntry.isComplete()) {
//TODO check pin with PinCodeManager
coroutineScope.launch { pinStateService.unlock() }
}
pinEntryState.value = pinEntry.process(event.pinKeypadModel)
}
PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true
PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false
PinUnlockEvents.SignOut -> {
if (showSignOutPrompt) {
showSignOutPrompt = false
coroutineScope.signOut(signOutAction)
}
}
PinUnlockEvents.OnUseBiometric -> {
//TODO
}
}
}
return PinUnlockState(
@@ -72,15 +106,43 @@ class PinUnlockPresenter @Inject constructor(
showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts,
showSignOutPrompt = showSignOutPrompt,
signOutAction = signOutAction.value,
eventSink = ::handleEvents
)
}
private fun PinEntry.process(pinKeypadModel: PinKeypadModel): PinEntry {
return when (pinKeypadModel) {
PinKeypadModel.Back -> deleteLast()
is PinKeypadModel.Number -> addDigit(pinKeypadModel.number)
PinKeypadModel.Empty -> this
private fun Async<PinEntry>.isComplete(): Boolean {
return dataOrNull()?.isComplete().orFalse()
}
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
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.Async
data class PinUnlockState(
val pinEntry: PinEntry,
val pinEntry: Async<PinEntry>,
val showWrongPinTitle: Boolean,
val remainingAttempts: Int,
val remainingAttempts: Async<Int>,
val showSignOutPrompt: Boolean,
val signOutAction: Async<String?>,
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 io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.Async
open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
override val values: Sequence<PinUnlockState>
@@ -27,6 +28,7 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
aPinUnlockState(showWrongPinTitle = true),
aPinUnlockState(showSignOutPrompt = true),
aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0),
aPinUnlockState(signOutAction = Async.Loading()),
)
}
@@ -35,10 +37,12 @@ fun aPinUnlockState(
remainingAttempts: Int = 3,
showWrongPinTitle: Boolean = false,
showSignOutPrompt: Boolean = false,
signOutAction: Async<String?> = Async.Uninitialized,
) = PinUnlockState(
pinEntry = pinEntry,
pinEntry = Async.Success(pinEntry),
showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts,
remainingAttempts = Async.Success(remainingAttempts),
showSignOutPrompt = showSignOutPrompt,
signOutAction = signOutAction,
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.PinEntry
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.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -79,7 +81,13 @@ fun PinUnlockView(
}
val footer = @Composable {
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 ->
@@ -107,23 +115,42 @@ fun PinUnlockView(
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,
)
Spacer(Modifier.height(8.dp))
val subtitle = if (state.showWrongPinTitle) {
pluralStringResource(id = R.plurals.screen_app_lock_subtitle_wrong_pin, count = state.remainingAttempts, state.remainingAttempts)
val remainingAttempts = state.remainingAttempts.dataOrNull()
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 {
stringResource(id = R.string.screen_app_lock_subtitle)
""
}
val subtitleColor = if (state.showWrongPinTitle) {
MaterialTheme.colorScheme.error
@@ -244,17 +276,21 @@ private fun PinUnlockHeader(
color = subtitleColor,
)
Spacer(Modifier.height(24.dp))
PinDotsRow(state.pinEntry)
if (state.pinEntry is Async.Success) {
PinDotsRow(state.pinEntry.data)
}
}
}
@Composable
private fun PinUnlockFooter(
onUseBiometric: () -> Unit,
onForgotPin: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
TextButton(text = "Use biometric", onClick = { })
TextButton(text = stringResource(id = R.string.screen_app_lock_forgot_pin), onClick = { })
TextButton(text = "Use biometric", onClick = onUseBiometric)
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"?>
<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">
<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="other">"Nesprávny PIN kód. Máte ešte %1$d pokusov"</item>
</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_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_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_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_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>
@@ -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_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_subtitle">"Máte 3 pokusy na odomknutie"</string>
<string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie"</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,15 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<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">
<item quantity="one">"Wrong PIN. You have %1$d more chance"</item>
<item quantity="other">"Wrong PIN. You have %1$d more chances"</item>
</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_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_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_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_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>
@@ -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_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_subtitle">"You have 3 attempts to unlock"</string>
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
</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
}
override suspend fun onWrongPin(): Int {
return remainingAttempts--
override suspend fun onWrongPin() {
remainingAttempts--
}
override suspend fun resetCounter() {
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? {
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.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.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.assertText
import io.element.android.features.lockscreen.impl.setup.validation.PinValidator
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -38,8 +45,13 @@ class SetupPinPresenterTest {
@Test
fun `present - complete flow`() = runTest {
val presenter = createSetupPinPresenter()
val pinCodeCreated = CompletableDeferred<Unit>()
val callback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
pinCodeCreated.complete(Unit)
}
}
val presenter = createSetupPinPresenter(callback)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -67,7 +79,9 @@ class SetupPinPresenterTest {
assertThat(state.setupPinFailure).isNull()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
}
awaitLastSequentialItem().also { state ->
consumeItemsUntilPredicate {
it.isConfirmationStep
}.last().also { state ->
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue()
@@ -86,7 +100,9 @@ class SetupPinPresenterTest {
assertThat(state.setupPinFailure).isNull()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
}
awaitLastSequentialItem().also { state ->
consumeItemsUntilPredicate {
it.isConfirmationStep
}.last().also { state ->
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue()
@@ -96,10 +112,23 @@ class SetupPinPresenterTest {
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertText(completePin)
}
pinCodeCreated.await()
}
}
private fun createSetupPinPresenter(): SetupPinPresenter {
return SetupPinPresenter(PinValidator(setOf(blacklistedPin)), aBuildMeta())
private fun createSetupPinPresenter(
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.turbine.test
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.state.DefaultLockScreenStateService
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.FakeMatrixClient
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.test.runTest
import org.junit.Test
@@ -37,16 +41,27 @@ class PinUnlockPresenterTest {
private val completePin = "1235"
@Test
fun `present - complete flow`() = runTest {
val presenter = createPinUnlockPresenter(this)
fun `present - success 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 {
awaitItem().also { state ->
state.pinEntry.assertEmpty()
assertThat(state.pinEntry).isInstanceOf(Async.Uninitialized::class.java)
assertThat(state.showWrongPinTitle).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('2')))
}
@@ -55,9 +70,55 @@ class PinUnlockPresenterTest {
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Empty))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5')))
}
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)
}
awaitLastSequentialItem().also { state ->
@@ -67,22 +128,33 @@ class PinUnlockPresenterTest {
}
awaitLastSequentialItem().also { state ->
assertThat(state.showSignOutPrompt).isEqualTo(false)
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5')))
state.eventSink(PinUnlockEvents.OnForgetPin)
}
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 {
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.PinUnlock, true)
private fun Async<PinEntry>.assertText(text: String) {
dataOrNull()?.assertText(text)
}
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(
lockScreenStateService,
pinCodeManager,
FakeMatrixClient(),
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.
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_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_waitlist_message_success">"Vítá vás %1$s!"</string>
</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.
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_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_waitlist_message_success">"Willkommen bei %1$s!"</string>
</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.
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_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_waitlist_message_success">"Bienvenue dans %1$s !"</string>
</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.
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_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_waitlist_message_success">"Bun venit la%1$s!"</string>
</resources>

View File

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

View File

@@ -25,6 +25,6 @@
<string name="screen_server_confirmation_message_register">"您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。"</string>
<string name="screen_server_confirmation_title_login">"您即將登入 %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_waitlist_message_success">"歡迎使用 %1$s"</string>
</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.
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_success">"You\'re in."</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>

View File

@@ -1,12 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<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_title">"Odhlásiť sa"</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 strat 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_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_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_preference_item">"Odhlásiť sa"</string>
</resources>

View File

@@ -1,12 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<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_title">"Sign 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_last_session_title">"Recovery not set up"</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_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_preference_item">"Sign out"</string>
</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_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_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_reactions_show_less">"Zobrazit méně"</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_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_notification_settings_mode_mentions_and_keywords">"Pouze zmínky a klíčová slova"</string>
</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_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_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_reactions_show_less">"Weniger 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_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_notification_settings_mode_mentions_and_keywords">"Nur Erwähnungen und Schlüsselwörter"</string>
</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_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_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_reactions_show_less">"Afficher moins"</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_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_notification_settings_mode_mentions_and_keywords">"Mentions et mots clés uniquement"</string>
</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_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_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_reactions_show_less">"Afișați mai puțin"</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_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_notification_settings_mode_mentions_and_keywords">"Numai mențiuni și cuvinte cheie"</string>
</resources>

View File

@@ -30,7 +30,6 @@
<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_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_reactions_show_less">"Показать меньше"</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_voice_message_tooltip">"Удерживайте для записи"</string>
<string name="screen_room_error_failed_processing_media">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Только упоминания и ключевые слова"</string>
</resources>

View File

@@ -14,6 +14,7 @@
<string name="screen_room_attachment_source_poll">"Anketa"</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_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_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>
@@ -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_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_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_reactions_show_less">"Zobraziť menej"</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_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_notification_settings_mode_mentions_and_keywords">"Iba zmienky a kľúčové slová"</string>
</resources>

View File

@@ -19,11 +19,11 @@
<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_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_more">"更多"</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_timeline_add_reaction">"新增表情符號"</string>
<string name="screen_room_timeline_less_reactions">"較少"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"僅限提及與關鍵字"</string>
</resources>

View File

@@ -12,7 +12,8 @@
<string name="screen_room_attachment_source_location">"Location"</string>
<string name="screen_room_attachment_source_poll">"Poll"</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_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>
@@ -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_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_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_reactions_show_less">"Show less"</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_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_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
</resources>

View File

@@ -50,6 +50,7 @@ dependencies {
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.features.rageshake.api)
implementation(projects.features.lockscreen.api)
implementation(projects.features.analytics.api)
implementation(projects.features.ftue.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.AssistedInject
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.impl.about.AboutNode
import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNode
@@ -51,6 +52,7 @@ import kotlinx.parcelize.Parcelize
class PreferencesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val lockScreenEntryPoint: LockScreenEntryPoint,
) : BackstackNode<PreferencesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<PreferencesEntryPoint.Params>().first().initialElement.toNavTarget(),
@@ -82,6 +84,9 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize
data object NotificationSettings : NavTarget
@Parcelize
data object LockScreenSettings : NavTarget
@Parcelize
data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget
@@ -117,6 +122,10 @@ class PreferencesFlowNode @AssistedInject constructor(
backstack.push(NavTarget.NotificationSettings)
}
override fun onOpenLockScreenSettings() {
backstack.push(NavTarget.LockScreenSettings)
}
override fun onOpenAdvancedSettings() {
backstack.push(NavTarget.AdvancedSettings)
}
@@ -168,6 +177,11 @@ class PreferencesFlowNode @AssistedInject constructor(
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
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 onOpenDeveloperSettings()
fun onOpenNotificationSettings()
fun onOpenLockScreenSettings()
fun onOpenAdvancedSettings()
fun onOpenUserProfile(matrixUser: MatrixUser)
}
@@ -93,6 +94,10 @@ class PreferencesRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenNotificationSettings() }
}
private fun onOpenLockScreenSettings() {
plugins<Callback>().forEach { it.onOpenLockScreenSettings() }
}
private fun onOpenUserProfile(matrixUser: MatrixUser) {
plugins<Callback>().forEach { it.onOpenUserProfile(matrixUser) }
}
@@ -115,6 +120,7 @@ class PreferencesRootNode @AssistedInject constructor(
onSuccessLogout = { onSuccessLogout(activity, it) },
onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) },
onOpenNotificationSettings = this::onOpenNotificationSettings,
onOpenLockScreenSettings = this::onOpenLockScreenSettings,
onOpenUserProfile = this::onOpenUserProfile,
)
}

View File

@@ -68,6 +68,10 @@ class PreferencesRootPresenter @Inject constructor(
LaunchedEffect(Unit) {
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
val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false)
@@ -95,6 +99,7 @@ class PreferencesRootPresenter @Inject constructor(
showAnalyticsSettings = hasAnalyticsProviders,
showDeveloperSettings = showDeveloperSettings,
showNotificationSettings = showNotificationSettings.value,
showLockScreenSettings = showLockScreenSettings.value,
snackbarMessage = snackbarMessage,
)
}

View File

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

View File

@@ -30,5 +30,6 @@ fun aPreferencesRootState() = PreferencesRootState(
showAnalyticsSettings = true,
showDeveloperSettings = true,
showNotificationSettings = true,
showLockScreenSettings = true,
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.padding
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.VerifiedUser
import androidx.compose.runtime.Composable
@@ -53,6 +54,7 @@ fun PreferencesRootView(
onManageAccountClicked: (url: String) -> Unit,
onOpenAnalytics: () -> Unit,
onOpenRageShake: () -> Unit,
onOpenLockScreenSettings: ()->Unit,
onOpenAbout: () -> Unit,
onOpenDeveloperSettings: () -> Unit,
onOpenAdvancedSettings: () -> Unit,
@@ -116,6 +118,13 @@ fun PreferencesRootView(
iconResourceId = CommonDrawables.ic_compound_info,
onClick = onOpenAbout,
)
if (state.showLockScreenSettings) {
PreferenceText(
title = stringResource(id = CommonStrings.common_screen_lock),
icon = Icons.Default.Lock,
onClick = onOpenLockScreenSettings,
)
}
HorizontalDivider()
if (state.devicesManagementUrl != null) {
PreferenceText(
@@ -183,6 +192,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onSuccessLogout = {},
onManageAccountClicked = {},
onOpenNotificationSettings = {},
onOpenLockScreenSettings = {},
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_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_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_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>
@@ -47,4 +46,5 @@
<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_topic_title">"Téma"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Pouze zmínky a klíčová slova"</string>
</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_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_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_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>
@@ -46,4 +45,5 @@
<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_topic_title">"Thema"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Nur Erwähnungen und Schlüsselwörter"</string>
</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_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_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_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>
@@ -46,4 +45,5 @@
<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_topic_title">"Sujet"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions et mots clés uniquement"</string>
</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_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_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_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>
@@ -46,4 +45,5 @@
<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_topic_title">"Subiect"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Numai mențiuni și cuvinte cheie"</string>
</resources>

View File

@@ -36,7 +36,6 @@
<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_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_dm_details_block_alert_action">"Заблокировать"</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_security_title">"Безопасность"</string>
<string name="screen_room_details_topic_title">"Тема"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Только упоминания и ключевые слова"</string>
</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_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_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_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>
@@ -47,4 +46,5 @@
<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_topic_title">"Téma"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Iba zmienky a kľúčové slová"</string>
</resources>

View File

@@ -26,7 +26,6 @@
<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_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_user">"封鎖使用者"</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_security_title">"安全性"</string>
<string name="screen_room_details_topic_title">"主題"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"僅限提及與關鍵字"</string>
</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_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_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_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>
@@ -46,4 +45,5 @@
<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_topic_title">"Topic"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
</resources>

View File

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

View File

@@ -40,7 +40,9 @@ class KeyStoreSecretKeyProvider @Inject constructor() : SecretKeyProvider {
// False positive lint issue
@SuppressLint("WrongConstant")
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)
?.secretKey
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_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_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_by_you">"您將主題變更為 %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_voice_message">"Hlasová správa"</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_summary">"Anketa: %1$s"</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_success">"Kľúč na obnovu potvrdený"</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_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>

View File

@@ -35,6 +35,7 @@
<string name="action_edit">"Edit"</string>
<string name="action_enable">"Enable"</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_forward">"Forward"</string>
<string name="action_invite">"Invite"</string>
@@ -52,7 +53,7 @@
<string name="action_no">"No"</string>
<string name="action_not_now">"Not now"</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_quick_reply">"Quick reply"</string>
<string name="action_quote">"Quote"</string>
@@ -146,6 +147,7 @@
<string name="common_server_url">"Server URL"</string>
<string name="common_settings">"Settings"</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_sticker">"Sticker"</string>
<string name="common_success">"Success"</string>
@@ -168,8 +170,11 @@
<string name="common_video">"Video"</string>
<string name="common_voice_message">"Voice message"</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_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_warning">"Warning"</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_success">"Recovery key confirmed"</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_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>

View File

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

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