Merge pull request #1642 from vector-im/feature/fga/pin_settings
PIN : settings and branch flow
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 = {}
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">"I’d 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">"You’ll 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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
28
features/lockscreen/test/build.gradle.kts
Normal file
28
features/lockscreen/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
<string name="screen_waitlist_message">"Il y a une forte demande pour %1$s sur %2$s à l’heure actuelle. Revenez sur l’application 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">"You’re 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>
|
||||
|
||||
@@ -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 stratiť 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -30,5 +30,6 @@ fun aPreferencesRootState() = PreferencesRootState(
|
||||
showAnalyticsSettings = true,
|
||||
showDeveloperSettings = true,
|
||||
showNotificationSettings = true,
|
||||
showLockScreenSettings = true,
|
||||
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
|
||||
)
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user