Merge branch 'develop' into feature/fga/pin_settings

This commit is contained in:
ganfra 2023-10-23 17:36:12 +02:00
commit eb15ca3901
62 changed files with 1212 additions and 463 deletions

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appconfig
object LockScreenConfig {
/**
* Whether the PIN is mandatory or not.
*/
const val IS_PIN_MANDATORY: Boolean = false
/**
* Some PINs are blacklisted.
*/
val PIN_BLACKLIST = setOf("0000", "1234")
/**
* The size of the PIN.
*/
const val PIN_SIZE = 4
}

View file

@ -250,9 +250,6 @@ koverMerged {
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*" excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
// Some options can't be tested at the moment // Some options can't be tested at the moment
excludes += "io.element.android.features.preferences.impl.developer.DeveloperSettingsPresenter$*" excludes += "io.element.android.features.preferences.impl.developer.DeveloperSettingsPresenter$*"
// Temporary until we have actually something to test.
excludes += "io.element.android.features.lockscreen.impl.auth.PinAuthenticationPresenter"
excludes += "io.element.android.features.lockscreen.impl.auth.PinAuthenticationPresenter$*"
} }
bound { bound {
minValue = 85 minValue = 85

View file

@ -30,9 +30,11 @@ anvil {
} }
dependencies { dependencies {
ksp(libs.showkase.processor)
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
api(projects.features.lockscreen.api) api(projects.features.lockscreen.api)
implementation(projects.appconfig)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
@ -40,6 +42,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api) implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.uiStrings)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
@ -50,6 +53,5 @@ dependencies {
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.cryptography.test) testImplementation(projects.libraries.cryptography.test)
testImplementation(projects.libraries.cryptography.impl) testImplementation(projects.libraries.cryptography.impl)
testImplementation(projects.libraries.featureflag.test)
ksp(libs.showkase.processor)
} }

View file

@ -27,8 +27,8 @@ import com.bumble.appyx.navmodel.backstack.BackStack
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.lockscreen.impl.auth.PinAuthenticationNode import io.element.android.features.lockscreen.impl.setup.SetupPinNode
import io.element.android.features.lockscreen.impl.create.CreatePinNode import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -41,7 +41,7 @@ class LockScreenFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
) : BackstackNode<LockScreenFlowNode.NavTarget>( ) : BackstackNode<LockScreenFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Auth, initialElement = NavTarget.Unlock,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -50,19 +50,19 @@ class LockScreenFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data object Auth : NavTarget data object Unlock : NavTarget
@Parcelize @Parcelize
data object Create : NavTarget data object Setup : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Auth -> { NavTarget.Unlock -> {
createNode<PinAuthenticationNode>(buildContext) createNode<PinUnlockNode>(buildContext)
} }
NavTarget.Create -> { NavTarget.Setup -> {
createNode<CreatePinNode>(buildContext) createNode<SetupPinNode>(buildContext)
} }
} }
} }

View file

@ -1,43 +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.auth
import androidx.compose.runtime.Composable
import io.element.android.features.lockscreen.api.LockScreenStateService
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class PinAuthenticationPresenter @Inject constructor(
private val pinStateService: LockScreenStateService,
private val coroutineScope: CoroutineScope,
) : Presenter<PinAuthenticationState> {
@Composable
override fun present(): PinAuthenticationState {
fun handleEvents(event: PinAuthenticationEvents) {
when (event) {
PinAuthenticationEvents.Unlock -> coroutineScope.launch { pinStateService.unlock() }
}
}
return PinAuthenticationState(
eventSink = ::handleEvents
)
}
}

View file

@ -1,30 +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.auth
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class PinAuthenticationStateProvider : PreviewParameterProvider<PinAuthenticationState> {
override val values: Sequence<PinAuthenticationState>
get() = sequenceOf(
aPinAuthenticationState(),
)
}
fun aPinAuthenticationState() = PinAuthenticationState(
eventSink = {}
)

View file

@ -1,84 +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.auth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Surface
@Composable
fun PinAuthenticationView(
state: PinAuthenticationState,
modifier: Modifier = Modifier,
) {
Surface(modifier) {
HeaderFooterPage(
modifier = Modifier
.systemBarsPadding()
.fillMaxSize(),
header = { PinAuthenticationHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
footer = { PinAuthenticationFooter(state) },
)
}
}
@Composable
private fun PinAuthenticationHeader(
modifier: Modifier = Modifier,
) {
IconTitleSubtitleMolecule(
modifier = modifier,
title = "Element X is locked",
subTitle = null,
iconImageVector = Icons.Default.Lock,
)
}
@Composable
private fun PinAuthenticationFooter(state: PinAuthenticationState) {
Button(
modifier = Modifier.fillMaxWidth(),
text = "Unlock",
onClick = {
state.eventSink(PinAuthenticationEvents.Unlock)
}
)
}
@Composable
@PreviewsDayNight
internal fun PinAuthenticationViewPreview(@PreviewParameter(PinAuthenticationStateProvider::class) state: PinAuthenticationState) {
ElementPreview {
PinAuthenticationView(
state = state,
)
}
}

View file

@ -0,0 +1,117 @@
/*
* 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.components
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.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
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
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.pinDigitBg
import io.element.android.libraries.theme.ElementTheme
@Composable
fun PinEntryTextField(
pinEntry: PinEntry,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
BasicTextField(
modifier = modifier,
value = TextFieldValue(pinEntry.toText()),
onValueChange = {
onValueChange(it.text)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
decorationBox = {
PinEntryRow(pinEntry = pinEntry)
}
)
}
@Composable
private fun PinEntryRow(
pinEntry: PinEntry,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
for (digit in pinEntry.digits) {
PinDigitView(digit = digit)
}
}
}
@Composable
private fun PinDigitView(
digit: PinDigit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(8.dp)
val appearanceModifier = when (digit) {
PinDigit.Empty -> {
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
}
is PinDigit.Filled -> {
Modifier.background(ElementTheme.colors.pinDigitBg, shape)
}
}
Box(
modifier = modifier
.size(48.dp)
.then(appearanceModifier),
contentAlignment = Alignment.Center,
) {
if (digit is PinDigit.Filled) {
Text(
text = digit.toText(),
style = ElementTheme.typography.fontHeadingMdBold
)
}
}
}
@PreviewsDayNight
@Composable
internal fun PinEntryTextFieldPreview() {
ElementPreview {
PinEntryTextField(
pinEntry = PinEntry.createEmpty(4).fillWith("12"),
onValueChange = {},
)
}
}

View file

@ -1,61 +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.create
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.lockscreen.impl.create.model.PinEntry
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
open class CreatePinStateProvider : PreviewParameterProvider<CreatePinState> {
override val values: Sequence<CreatePinState>
get() = sequenceOf(
aCreatePinState(),
aCreatePinState(
choosePinEntry = PinEntry.empty(4).fillWith("12")
),
aCreatePinState(
choosePinEntry = PinEntry.empty(4).fillWith("1789"),
isConfirmationStep = true,
),
aCreatePinState(
choosePinEntry = PinEntry.empty(4).fillWith("1789"),
confirmPinEntry = PinEntry.empty(4).fillWith("1788"),
isConfirmationStep = true,
creationFailure = CreatePinFailure.PinsDontMatch
),
aCreatePinState(
choosePinEntry = PinEntry.empty(4).fillWith("1111"),
creationFailure = CreatePinFailure.PinBlacklisted
),
)
}
fun aCreatePinState(
choosePinEntry: PinEntry = PinEntry.empty(4),
confirmPinEntry: PinEntry = PinEntry.empty(4),
isConfirmationStep: Boolean = false,
creationFailure: CreatePinFailure? = null,
) = CreatePinState(
choosePinEntry = choosePinEntry,
confirmPinEntry = confirmPinEntry,
isConfirmationStep = isConfirmationStep,
createPinFailure = creationFailure,
appName = "Element",
eventSink = {}
)

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.create.model package io.element.android.features.lockscreen.impl.pin.model
sealed interface PinDigit { sealed interface PinDigit {
data object Empty : PinDigit data object Empty : PinDigit

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.create.model package io.element.android.features.lockscreen.impl.pin.model
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@ -24,7 +24,7 @@ data class PinEntry(
) { ) {
companion object { companion object {
fun empty(size: Int): PinEntry { fun createEmpty(size: Int): PinEntry {
val digits = List(size) { PinDigit.Empty } val digits = List(size) { PinDigit.Empty }
return PinEntry( return PinEntry(
digits = digits.toPersistentList() digits = digits.toPersistentList()
@ -50,14 +50,36 @@ data class PinEntry(
return copy(digits = newDigits.toPersistentList()) return copy(digits = newDigits.toPersistentList())
} }
fun clear(): PinEntry { fun deleteLast(): PinEntry {
return fillWith("") if (isEmpty()) return this
val newDigits = digits.toMutableList()
newDigits.indexOfLast { it is PinDigit.Filled }.also { lastFilled ->
newDigits[lastFilled] = PinDigit.Empty
}
return copy(digits = newDigits.toPersistentList())
} }
fun isPinComplete(): Boolean { fun addDigit(digit: Char): PinEntry {
if (isComplete()) return this
val newDigits = digits.toMutableList()
newDigits.indexOfFirst { it is PinDigit.Empty }.also { firstEmpty ->
newDigits[firstEmpty] = PinDigit.Filled(digit)
}
return copy(digits = newDigits.toPersistentList())
}
fun clear(): PinEntry {
return createEmpty(size)
}
fun isComplete(): Boolean {
return digits.all { it is PinDigit.Filled } return digits.all { it is PinDigit.Filled }
} }
fun isEmpty(): Boolean {
return digits.all { it is PinDigit.Empty }
}
fun toText(): String { fun toText(): String {
return digits.joinToString("") { return digits.joinToString("") {
it.toText() it.toText()

View file

@ -14,9 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.create.validation package io.element.android.features.lockscreen.impl.setup
sealed interface CreatePinFailure { sealed interface SetupPinEvents {
data object PinBlacklisted : CreatePinFailure data class OnPinEntryChanged(val entryAsText: String) : SetupPinEvents
data object PinsDontMatch : CreatePinFailure data object ClearFailure : SetupPinEvents
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.create package io.element.android.features.lockscreen.impl.setup
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -27,16 +27,16 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
class CreatePinNode @AssistedInject constructor( class SetupPinNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: CreatePinPresenter, private val presenter: SetupPinPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
CreatePinView( SetupPinView(
state = state, state = state,
onBackClicked = { }, onBackClicked = { },
modifier = modifier modifier = modifier

View file

@ -14,88 +14,87 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.create package io.element.android.features.lockscreen.impl.setup
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.features.lockscreen.impl.create.model.PinEntry import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.create.validation.PinValidator import io.element.android.features.lockscreen.impl.setup.validation.PinValidator
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import javax.inject.Inject import javax.inject.Inject
private const val PIN_SIZE = 4 class SetupPinPresenter @Inject constructor(
class CreatePinPresenter @Inject constructor(
private val pinValidator: PinValidator, private val pinValidator: PinValidator,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) : Presenter<CreatePinState> { ) : Presenter<SetupPinState> {
@Composable @Composable
override fun present(): CreatePinState { override fun present(): SetupPinState {
var choosePinEntry by remember { var choosePinEntry by remember {
mutableStateOf(PinEntry.empty(PIN_SIZE)) mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE))
} }
var confirmPinEntry by remember { var confirmPinEntry by remember {
mutableStateOf(PinEntry.empty(PIN_SIZE)) mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE))
} }
var isConfirmationStep by remember { var isConfirmationStep by remember {
mutableStateOf(false) mutableStateOf(false)
} }
var createPinFailure by remember { var setupPinFailure by remember {
mutableStateOf<CreatePinFailure?>(null) mutableStateOf<SetupPinFailure?>(null)
} }
fun handleEvents(event: CreatePinEvents) { fun handleEvents(event: SetupPinEvents) {
when (event) { when (event) {
is CreatePinEvents.OnPinEntryChanged -> { is SetupPinEvents.OnPinEntryChanged -> {
if (isConfirmationStep) { if (isConfirmationStep) {
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText) confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
if (confirmPinEntry.isPinComplete()) { if (confirmPinEntry.isComplete()) {
if (confirmPinEntry == choosePinEntry) { if (confirmPinEntry == choosePinEntry) {
//TODO save in db and navigate to next screen //TODO save in db and navigate to next screen
} else { } else {
createPinFailure = CreatePinFailure.PinsDontMatch setupPinFailure = SetupPinFailure.PinsDontMatch
} }
} }
} else { } else {
choosePinEntry = choosePinEntry.fillWith(event.entryAsText) choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
if (choosePinEntry.isPinComplete()) { if (choosePinEntry.isComplete()) {
when (val pinValidationResult = pinValidator.isPinValid(choosePinEntry)) { when (val pinValidationResult = pinValidator.isPinValid(choosePinEntry)) {
is PinValidator.Result.Invalid -> { is PinValidator.Result.Invalid -> {
createPinFailure = pinValidationResult.failure setupPinFailure = pinValidationResult.failure
} }
PinValidator.Result.Valid -> isConfirmationStep = true PinValidator.Result.Valid -> isConfirmationStep = true
} }
} }
} }
} }
CreatePinEvents.ClearFailure -> { SetupPinEvents.ClearFailure -> {
when (createPinFailure) { when (setupPinFailure) {
is CreatePinFailure.PinsDontMatch -> { is SetupPinFailure.PinsDontMatch -> {
choosePinEntry = PinEntry.empty(PIN_SIZE) choosePinEntry = choosePinEntry.clear()
confirmPinEntry = PinEntry.empty(PIN_SIZE) confirmPinEntry = confirmPinEntry.clear()
} }
is CreatePinFailure.PinBlacklisted -> { is SetupPinFailure.PinBlacklisted -> {
choosePinEntry = PinEntry.empty(PIN_SIZE) choosePinEntry = choosePinEntry.clear()
} }
null -> Unit null -> Unit
} }
isConfirmationStep = false isConfirmationStep = false
createPinFailure = null setupPinFailure = null
} }
} }
} }
return CreatePinState( return SetupPinState(
choosePinEntry = choosePinEntry, choosePinEntry = choosePinEntry,
confirmPinEntry = confirmPinEntry, confirmPinEntry = confirmPinEntry,
isConfirmationStep = isConfirmationStep, isConfirmationStep = isConfirmationStep,
createPinFailure = createPinFailure, setupPinFailure = setupPinFailure,
appName = buildMeta.applicationName, appName = buildMeta.applicationName,
eventSink = ::handleEvents eventSink = ::handleEvents
) )

View file

@ -14,20 +14,19 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.create package io.element.android.features.lockscreen.impl.setup
import io.element.android.features.lockscreen.impl.create.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
data class CreatePinState( data class SetupPinState(
val choosePinEntry: PinEntry, val choosePinEntry: PinEntry,
val confirmPinEntry: PinEntry, val confirmPinEntry: PinEntry,
val isConfirmationStep: Boolean, val isConfirmationStep: Boolean,
val createPinFailure: CreatePinFailure?, val setupPinFailure: SetupPinFailure?,
val appName: String, val appName: String,
val eventSink: (CreatePinEvents) -> Unit val eventSink: (SetupPinEvents) -> Unit
) { ) {
val pinSize = choosePinEntry.size
val activePinEntry = if (isConfirmationStep) { val activePinEntry = if (isConfirmationStep) {
confirmPinEntry confirmPinEntry
} else { } else {

View file

@ -0,0 +1,61 @@
/*
* 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.setup
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
open class SetupPinStateProvider : PreviewParameterProvider<SetupPinState> {
override val values: Sequence<SetupPinState>
get() = sequenceOf(
aSetupPinState(),
aSetupPinState(
choosePinEntry = PinEntry.createEmpty(4).fillWith("12")
),
aSetupPinState(
choosePinEntry = PinEntry.createEmpty(4).fillWith("1789"),
isConfirmationStep = true,
),
aSetupPinState(
choosePinEntry = PinEntry.createEmpty(4).fillWith("1789"),
confirmPinEntry = PinEntry.createEmpty(4).fillWith("1788"),
isConfirmationStep = true,
creationFailure = SetupPinFailure.PinsDontMatch
),
aSetupPinState(
choosePinEntry = PinEntry.createEmpty(4).fillWith("1111"),
creationFailure = SetupPinFailure.PinBlacklisted
),
)
}
fun aSetupPinState(
choosePinEntry: PinEntry = PinEntry.createEmpty(4),
confirmPinEntry: PinEntry = PinEntry.createEmpty(4),
isConfirmationStep: Boolean = false,
creationFailure: SetupPinFailure? = null,
) = SetupPinState(
choosePinEntry = choosePinEntry,
confirmPinEntry = confirmPinEntry,
isConfirmationStep = isConfirmationStep,
setupPinFailure = creationFailure,
appName = "Element",
eventSink = {}
)

View file

@ -16,23 +16,14 @@
@file:OptIn(ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.lockscreen.impl.create package io.element.android.features.lockscreen.impl.setup
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.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
@ -41,28 +32,22 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.lockscreen.impl.R import io.element.android.features.lockscreen.impl.R
import io.element.android.features.lockscreen.impl.create.model.PinDigit import io.element.android.features.lockscreen.impl.components.PinEntryTextField
import io.element.android.features.lockscreen.impl.create.model.PinEntry import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.pinDigitBg
import io.element.android.libraries.theme.ElementTheme
@Composable @Composable
fun CreatePinView( fun SetupPinView(
state: CreatePinState, state: SetupPinState,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -86,15 +71,15 @@ fun CreatePinView(
.verticalScroll(state = scrollState) .verticalScroll(state = scrollState)
.padding(vertical = 16.dp, horizontal = 20.dp), .padding(vertical = 16.dp, horizontal = 20.dp),
) { ) {
CreatePinHeader(state.isConfirmationStep, state.appName) SetupPinHeader(state.isConfirmationStep, state.appName)
CreatePinContent(state) SetupPinContent(state)
} }
} }
) )
} }
@Composable @Composable
private fun CreatePinHeader( private fun SetupPinHeader(
isValidationStep: Boolean, isValidationStep: Boolean,
appName: String, appName: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -116,118 +101,52 @@ private fun CreatePinHeader(
} }
@Composable @Composable
private fun CreatePinContent( private fun SetupPinContent(
state: CreatePinState, state: SetupPinState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
PinEntryTextField( PinEntryTextField(
state.activePinEntry, state.activePinEntry,
onValueChange = { onValueChange = {
state.eventSink(CreatePinEvents.OnPinEntryChanged(it)) state.eventSink(SetupPinEvents.OnPinEntryChanged(it))
}, },
modifier = modifier modifier = modifier
.padding(top = 36.dp) .padding(top = 36.dp)
.fillMaxWidth() .fillMaxWidth()
) )
if (state.createPinFailure != null) { if (state.setupPinFailure != null) {
ErrorDialog( ErrorDialog(
modifier = modifier, modifier = modifier,
title = state.createPinFailure.title(), title = state.setupPinFailure.title(),
content = state.createPinFailure.content(), content = state.setupPinFailure.content(),
onDismiss = { onDismiss = {
state.eventSink(CreatePinEvents.ClearFailure) state.eventSink(SetupPinEvents.ClearFailure)
} }
) )
} }
} }
@Composable @Composable
private fun CreatePinFailure.content(): String { private fun SetupPinFailure.content(): String {
return when (this) { return when (this) {
CreatePinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_content) SetupPinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_content)
CreatePinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_content) SetupPinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_content)
} }
} }
@Composable @Composable
private fun CreatePinFailure.title(): String { private fun SetupPinFailure.title(): String {
return when (this) { return when (this) {
CreatePinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_title) SetupPinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_title)
CreatePinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_title) SetupPinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_title)
}
}
@Composable
private fun PinEntryTextField(
pinEntry: PinEntry,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
BasicTextField(
modifier = modifier,
value = TextFieldValue(pinEntry.toText()),
onValueChange = {
onValueChange(it.text)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
decorationBox = {
PinEntryRow(pinEntry = pinEntry)
}
)
}
@Composable
private fun PinEntryRow(
pinEntry: PinEntry,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
for (digit in pinEntry.digits) {
PinDigitView(digit = digit)
}
}
}
@Composable
private fun PinDigitView(
digit: PinDigit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(8.dp)
val appearanceModifier = when (digit) {
PinDigit.Empty -> {
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
}
is PinDigit.Filled -> {
Modifier.background(ElementTheme.colors.pinDigitBg, shape)
}
}
Box(
modifier = modifier
.size(48.dp)
.then(appearanceModifier),
contentAlignment = Alignment.Center,
) {
if (digit is PinDigit.Filled) {
Text(
text = digit.toText(),
style = ElementTheme.typography.fontHeadingMdBold
)
}
} }
} }
@Composable @Composable
@PreviewsDayNight @PreviewsDayNight
internal fun CreatePinViewPreview(@PreviewParameter(CreatePinStateProvider::class) state: CreatePinState) { internal fun SetupPinViewPreview(@PreviewParameter(SetupPinStateProvider::class) state: SetupPinState) {
ElementPreview { ElementPreview {
CreatePinView( SetupPinView(
state = state, state = state,
onBackClicked = {}, onBackClicked = {},
) )

View file

@ -14,29 +14,27 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.create.validation package io.element.android.features.lockscreen.impl.setup.validation
import androidx.annotation.VisibleForTesting import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.impl.create.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import javax.inject.Inject import javax.inject.Inject
class PinValidator @Inject constructor() { class PinValidator internal constructor(private val pinBlacklist: Set<String>) {
companion object { @Inject
@VisibleForTesting constructor() : this(LockScreenConfig.PIN_BLACKLIST)
val BLACKLIST = listOf("0000", "1234")
}
sealed interface Result { sealed interface Result {
data object Valid : Result data object Valid : Result
data class Invalid(val failure: CreatePinFailure) : Result data class Invalid(val failure: SetupPinFailure) : Result
} }
fun isPinValid(pinEntry: PinEntry): Result { fun isPinValid(pinEntry: PinEntry): Result {
val pinAsText = pinEntry.toText() val pinAsText = pinEntry.toText()
val isBlacklisted = BLACKLIST.any { it == pinAsText } val isBlacklisted = pinBlacklist.any { it == pinAsText }
return if (isBlacklisted) { return if (isBlacklisted) {
Result.Invalid(CreatePinFailure.PinBlacklisted) Result.Invalid(SetupPinFailure.PinBlacklisted)
} else { } else {
Result.Valid Result.Valid
} }

View file

@ -14,8 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.auth package io.element.android.features.lockscreen.impl.setup.validation
sealed interface PinAuthenticationEvents { sealed interface SetupPinFailure {
data object Unlock : PinAuthenticationEvents data object PinBlacklisted : SetupPinFailure
data object PinsDontMatch : SetupPinFailure
} }

View file

@ -25,13 +25,12 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val GRACE_PERIOD_IN_MILLIS = 90 * 1000L //private const val GRACE_PERIOD_IN_MILLIS = 90 * 1000L
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@ -57,7 +56,7 @@ class DefaultLockScreenStateService @Inject constructor(
override suspend fun entersBackground() = coroutineScope { override suspend fun entersBackground() = coroutineScope {
lockJob = launch { lockJob = launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)) { if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)) {
delay(GRACE_PERIOD_IN_MILLIS) //delay(GRACE_PERIOD_IN_MILLIS)
_lockScreenState.value = LockScreenState.Locked _lockScreenState.value = LockScreenState.Locked
} }
} }

View file

@ -0,0 +1,25 @@
/*
* 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.unlock
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
sealed interface PinUnlockEvents {
data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents
data object OnForgetPin : PinUnlockEvents
data object ClearSignOutPrompt : PinUnlockEvents
}

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.auth package io.element.android.features.lockscreen.impl.unlock
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -27,16 +27,16 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
class PinAuthenticationNode @AssistedInject constructor( class PinUnlockNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: PinAuthenticationPresenter, private val presenter: PinUnlockPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
PinAuthenticationView( PinUnlockView(
state = state, state = state,
modifier = modifier modifier = modifier
) )

View file

@ -0,0 +1,86 @@
/*
* 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.unlock
import androidx.compose.runtime.Composable
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.model.PinEntry
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class PinUnlockPresenter @Inject constructor(
private val pinStateService: LockScreenStateService,
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))
}
var remainingAttempts by rememberSaveable {
//TODO fetch from db
mutableIntStateOf(3)
}
var showWrongPinTitle by rememberSaveable {
mutableStateOf(false)
}
var showSignOutPrompt by rememberSaveable {
mutableStateOf(false)
}
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() }
}
}
PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true
PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false
}
}
return PinUnlockState(
pinEntry = pinEntry,
showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts,
showSignOutPrompt = showSignOutPrompt,
eventSink = ::handleEvents
)
}
private fun PinEntry.process(pinKeypadModel: PinKeypadModel): PinEntry {
return when (pinKeypadModel) {
PinKeypadModel.Back -> deleteLast()
is PinKeypadModel.Number -> addDigit(pinKeypadModel.number)
PinKeypadModel.Empty -> this
}
}
}

View file

@ -0,0 +1,29 @@
/*
* 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.unlock
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
data class PinUnlockState(
val pinEntry: PinEntry,
val showWrongPinTitle: Boolean,
val remainingAttempts: Int,
val showSignOutPrompt: Boolean,
val eventSink: (PinUnlockEvents) -> Unit
) {
val isSignOutPromptCancellable = remainingAttempts > 0
}

View file

@ -0,0 +1,44 @@
/*
* 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.unlock
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
override val values: Sequence<PinUnlockState>
get() = sequenceOf(
aPinUnlockState(),
aPinUnlockState(pinEntry = PinEntry.createEmpty(4).fillWith("12")),
aPinUnlockState(showWrongPinTitle = true),
aPinUnlockState(showSignOutPrompt = true),
aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0),
)
}
fun aPinUnlockState(
pinEntry: PinEntry = PinEntry.createEmpty(4),
remainingAttempts: Int = 3,
showWrongPinTitle: Boolean = false,
showSignOutPrompt: Boolean = false,
) = PinUnlockState(
pinEntry = pinEntry,
showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts,
showSignOutPrompt = showSignOutPrompt,
eventSink = {}
)

View file

@ -0,0 +1,270 @@
/*
* 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.unlock
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
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.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun PinUnlockView(
state: PinUnlockState,
modifier: Modifier = Modifier,
) {
Surface(modifier) {
BoxWithConstraints {
val commonModifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.padding(all = 20.dp)
val header = @Composable {
PinUnlockHeader(
state = state,
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)
)
}
val footer = @Composable {
PinUnlockFooter(
modifier = Modifier.padding(top = 24.dp)
)
}
val content = @Composable { constraints: BoxWithConstraintsScope ->
PinKeypad(
onClick = {
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(it))
},
maxWidth = constraints.maxWidth,
maxHeight = constraints.maxHeight,
horizontalAlignment = Alignment.CenterHorizontally,
)
}
if (maxHeight < 600.dp) {
PinUnlockCompactView(
header = header,
footer = footer,
content = content,
modifier = commonModifier,
)
} else {
PinUnlockExpandedView(
header = header,
footer = footer,
content = content,
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 = {},
)
}
}
}
}
}
@Composable
private fun PinUnlockCompactView(
header: @Composable () -> Unit,
footer: @Composable () -> Unit,
modifier: Modifier = Modifier,
content: @Composable BoxWithConstraintsScope.() -> Unit,
) {
Row(modifier = modifier) {
Column(Modifier.weight(1f)) {
header()
Spacer(modifier = Modifier.height(24.dp))
footer()
}
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.Center,
) {
content()
}
}
}
@Composable
private fun PinUnlockExpandedView(
header: @Composable () -> Unit,
footer: @Composable () -> Unit,
modifier: Modifier = Modifier,
content: @Composable BoxWithConstraintsScope.() -> Unit,
) {
Column(
modifier = modifier,
) {
header()
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(top = 40.dp),
) {
content()
}
footer()
}
}
@Composable
private fun PinDotsRow(
pinEntry: PinEntry,
modifier: Modifier = Modifier,
) {
Row(modifier, horizontalArrangement = spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
for (digit in pinEntry.digits) {
PinDot(isFilled = digit is PinDigit.Filled)
}
}
}
@Composable
private fun PinDot(
isFilled: Boolean,
modifier: Modifier = Modifier,
) {
val backgroundColor = if (isFilled) {
ElementTheme.colors.iconPrimary
} else {
ElementTheme.colors.bgSubtlePrimary
}
Box(
modifier = modifier
.size(14.dp)
.background(backgroundColor, CircleShape)
)
}
@Composable
private fun PinUnlockHeader(
state: PinUnlockState,
modifier: Modifier = Modifier,
) {
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
modifier = Modifier
.size(32.dp),
tint = ElementTheme.colors.iconPrimary,
imageVector = Icons.Filled.Lock,
contentDescription = "",
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = CommonStrings.common_enter_your_pin),
modifier = Modifier
.fillMaxWidth(),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontHeadingMdBold,
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)
} else {
stringResource(id = R.string.screen_app_lock_subtitle)
}
val subtitleColor = if (state.showWrongPinTitle) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.secondary
}
Text(
text = subtitle,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyMdRegular,
color = subtitleColor,
)
Spacer(Modifier.height(24.dp))
PinDotsRow(state.pinEntry)
}
}
@Composable
private fun PinUnlockFooter(
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 = { })
}
}
@Composable
@PreviewsDayNight
internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
ElementPreview {
PinUnlockView(
state = state,
)
}
}

View file

@ -0,0 +1,214 @@
/*
* 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.unlock.keypad
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtMost
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.times
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
private val spaceBetweenPinKey = 16.dp
private val maxSizePinKey = 80.dp
@Composable
fun PinKeypad(
onClick: (PinKeypadModel) -> Unit,
maxWidth: Dp,
maxHeight: Dp,
modifier: Modifier = Modifier,
verticalAlignment: Alignment.Vertical = Alignment.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
) {
val pinKeyMaxWidth = ((maxWidth - 2 * spaceBetweenPinKey) / 3).coerceAtMost(maxSizePinKey)
val pinKeyMaxHeight = ((maxHeight - 3 * spaceBetweenPinKey) / 4).coerceAtMost(maxSizePinKey)
val pinKeySize = if (pinKeyMaxWidth < pinKeyMaxHeight) pinKeyMaxWidth else pinKeyMaxHeight
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
Column(
modifier = modifier,
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment,
) {
PinKeypadRow(
pinKeySize = pinKeySize,
verticalAlignment = verticalAlignment,
horizontalArrangement = horizontalArrangement,
models = persistentListOf(PinKeypadModel.Number('1'), PinKeypadModel.Number('2'), PinKeypadModel.Number('3')),
onClick = onClick,
)
PinKeypadRow(
pinKeySize = pinKeySize,
verticalAlignment = verticalAlignment,
horizontalArrangement = horizontalArrangement,
models = persistentListOf(PinKeypadModel.Number('4'), PinKeypadModel.Number('5'), PinKeypadModel.Number('6')),
onClick = onClick,
)
PinKeypadRow(
pinKeySize = pinKeySize,
verticalAlignment = verticalAlignment,
horizontalArrangement = horizontalArrangement,
models = persistentListOf(PinKeypadModel.Number('7'), PinKeypadModel.Number('8'), PinKeypadModel.Number('9')),
onClick = onClick,
)
PinKeypadRow(
pinKeySize = pinKeySize,
verticalAlignment = verticalAlignment,
horizontalArrangement = horizontalArrangement,
models = persistentListOf(PinKeypadModel.Empty, PinKeypadModel.Number('0'), PinKeypadModel.Back),
onClick = onClick,
)
}
}
@Composable
private fun PinKeypadRow(
models: ImmutableList<PinKeypadModel>,
onClick: (PinKeypadModel) -> Unit,
pinKeySize: Dp,
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
) {
Row(
horizontalArrangement = horizontalArrangement,
verticalAlignment = verticalAlignment,
modifier = modifier.fillMaxWidth(),
) {
val commonModifier = Modifier.size(pinKeySize)
for (model in models) {
when (model) {
is PinKeypadModel.Empty -> {
Spacer(modifier = commonModifier)
}
is PinKeypadModel.Back -> {
PinKeypadBackButton(
modifier = commonModifier,
onClick = { onClick(model) },
)
}
is PinKeypadModel.Number -> {
PinKeyBadDigitButton(
size = pinKeySize,
modifier = commonModifier,
digit = model.number.toString(),
onClick = { onClick(model) },
)
}
}
}
}
}
@Composable
private fun PinKeypadButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(CircleShape)
.background(color = ElementTheme.colors.bgSubtlePrimary)
.clickable(onClick = onClick),
content = content
)
}
@Composable
private fun PinKeyBadDigitButton(
digit: String,
size: Dp,
onClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
PinKeypadButton(
modifier = modifier,
onClick = { onClick(digit) }
) {
val fontSize = size.toSp() / 2
val originalFont = ElementTheme.typography.fontHeadingXlBold
val ratio = fontSize.value / originalFont.fontSize.value
val lineHeight = originalFont.lineHeight * ratio
Text(
text = digit,
color = ElementTheme.colors.textPrimary,
style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp),
)
}
}
@Composable
private fun PinKeypadBackButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
PinKeypadButton(
modifier = modifier,
onClick = onClick,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Backspace,
contentDescription = null,
)
}
}
@Composable
@PreviewsDayNight
internal fun PinKeypadPreview() {
ElementPreview {
BoxWithConstraints {
PinKeypad(
maxWidth = maxWidth,
maxHeight = maxHeight,
onClick = {}
)
}
}
}

View file

@ -14,9 +14,13 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.create package io.element.android.features.lockscreen.impl.unlock.keypad
sealed interface CreatePinEvents { import androidx.compose.runtime.Immutable
data class OnPinEntryChanged(val entryAsText: String) : CreatePinEvents
data object ClearFailure : CreatePinEvents @Immutable
sealed interface PinKeypadModel {
data object Empty : PinKeypadModel
data object Back : PinKeypadModel
data class Number(val number: Char) : PinKeypadModel
} }

View file

@ -14,8 +14,15 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.auth package io.element.android.features.lockscreen.impl.pin.model
data class PinAuthenticationState( import com.google.common.truth.Truth.assertThat
val eventSink: (PinAuthenticationEvents) -> Unit
) fun PinEntry.assertText(text: String) {
assertThat(toText()).isEqualTo(text)
}
fun PinEntry.assertEmpty() {
val isEmpty = digits.all { it is PinDigit.Empty }
assertThat(isEmpty).isTrue()
}

View file

@ -14,24 +14,24 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.lockscreen.impl.create package io.element.android.features.lockscreen.impl.setup
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.create.model.PinDigit import io.element.android.features.lockscreen.impl.pin.model.assertEmpty
import io.element.android.features.lockscreen.impl.create.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.assertText
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure import io.element.android.features.lockscreen.impl.setup.validation.PinValidator
import io.element.android.features.lockscreen.impl.create.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.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class CreatePinPresenterTest { class SetupPinPresenterTest {
private val blacklistedPin = PinValidator.BLACKLIST.first() private val blacklistedPin = "1234"
private val halfCompletePin = "12" private val halfCompletePin = "12"
private val completePin = "1235" private val completePin = "1235"
private val mismatchedPin = "1236" private val mismatchedPin = "1236"
@ -39,58 +39,58 @@ class CreatePinPresenterTest {
@Test @Test
fun `present - complete flow`() = runTest { fun `present - complete flow`() = runTest {
val presenter = createCreatePinPresenter() val presenter = createSetupPinPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitItem().also { state -> awaitItem().also { state ->
state.choosePinEntry.assertEmpty() state.choosePinEntry.assertEmpty()
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.createPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse() assertThat(state.isConfirmationStep).isFalse()
state.eventSink(CreatePinEvents.OnPinEntryChanged(halfCompletePin)) state.eventSink(SetupPinEvents.OnPinEntryChanged(halfCompletePin))
} }
awaitItem().also { state -> awaitItem().also { state ->
state.choosePinEntry.assertText(halfCompletePin) state.choosePinEntry.assertText(halfCompletePin)
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.createPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse() assertThat(state.isConfirmationStep).isFalse()
state.eventSink(CreatePinEvents.OnPinEntryChanged(blacklistedPin)) state.eventSink(SetupPinEvents.OnPinEntryChanged(blacklistedPin))
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(blacklistedPin) state.choosePinEntry.assertText(blacklistedPin)
assertThat(state.createPinFailure).isEqualTo(CreatePinFailure.PinBlacklisted) assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinBlacklisted)
state.eventSink(CreatePinEvents.ClearFailure) state.eventSink(SetupPinEvents.ClearFailure)
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertEmpty() state.choosePinEntry.assertEmpty()
assertThat(state.createPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
state.eventSink(CreatePinEvents.OnPinEntryChanged(completePin)) state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue() assertThat(state.isConfirmationStep).isTrue()
state.eventSink(CreatePinEvents.OnPinEntryChanged(mismatchedPin)) state.eventSink(SetupPinEvents.OnPinEntryChanged(mismatchedPin))
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertText(mismatchedPin) state.confirmPinEntry.assertText(mismatchedPin)
assertThat(state.createPinFailure).isEqualTo(CreatePinFailure.PinsDontMatch) assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinsDontMatch)
state.eventSink(CreatePinEvents.ClearFailure) state.eventSink(SetupPinEvents.ClearFailure)
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertEmpty() state.choosePinEntry.assertEmpty()
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isFalse() assertThat(state.isConfirmationStep).isFalse()
assertThat(state.createPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
state.eventSink(CreatePinEvents.OnPinEntryChanged(completePin)) state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue() assertThat(state.isConfirmationStep).isTrue()
state.eventSink(CreatePinEvents.OnPinEntryChanged(completePin)) state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
} }
awaitItem().also { state -> awaitItem().also { state ->
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
@ -99,16 +99,7 @@ class CreatePinPresenterTest {
} }
} }
private fun PinEntry.assertText(text: String) { private fun createSetupPinPresenter(): SetupPinPresenter {
assertThat(toText()).isEqualTo(text) return SetupPinPresenter(PinValidator(setOf(blacklistedPin)), aBuildMeta())
}
private fun PinEntry.assertEmpty() {
val isEmpty = digits.all { it is PinDigit.Empty }
assertThat(isEmpty).isTrue()
}
private fun createCreatePinPresenter(): CreatePinPresenter {
return CreatePinPresenter(PinValidator(), aBuildMeta())
} }
} }

View file

@ -0,0 +1,89 @@
/*
* 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.unlock
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.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.tests.testutils.awaitLastSequentialItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PinUnlockPresenterTest {
private val halfCompletePin = "12"
private val completePin = "1235"
@Test
fun `present - complete flow`() = runTest {
val presenter = createPinUnlockPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().also { state ->
state.pinEntry.assertEmpty()
assertThat(state.showWrongPinTitle).isFalse()
assertThat(state.showSignOutPrompt).isFalse()
assertThat(state.remainingAttempts).isEqualTo(3)
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
}
awaitLastSequentialItem().also { state ->
state.pinEntry.assertText(halfCompletePin)
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Empty))
}
awaitLastSequentialItem().also { state ->
state.pinEntry.assertText(halfCompletePin)
state.eventSink(PinUnlockEvents.OnForgetPin)
}
awaitLastSequentialItem().also { state ->
assertThat(state.showSignOutPrompt).isEqualTo(true)
assertThat(state.isSignOutPromptCancellable).isEqualTo(true)
state.eventSink(PinUnlockEvents.ClearSignOutPrompt)
}
awaitLastSequentialItem().also { state ->
assertThat(state.showSignOutPrompt).isEqualTo(false)
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5')))
}
awaitLastSequentialItem().also { state ->
state.pinEntry.assertText(completePin)
}
}
}
private suspend fun createPinUnlockPresenter(scope: CoroutineScope): PinUnlockPresenter {
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.PinUnlock, true)
}
val lockScreenStateService = DefaultLockScreenStateService(featureFlagService)
return PinUnlockPresenter(
lockScreenStateService,
scope,
)
}
}

View file

@ -29,6 +29,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
@ -61,7 +62,7 @@ fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TimelineEncryptedHistoryBannerViewPreview() { internal fun TimelineEncryptedHistoryBannerViewPreview() {
ElementTheme { ElementPreview {
TimelineEncryptedHistoryBannerView() TimelineEncryptedHistoryBannerView()
} }
} }

View file

@ -155,7 +155,7 @@ sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", ver
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
sqlite = "androidx.sqlite:sqlite-ktx:2.3.1" sqlite = "androidx.sqlite:sqlite-ktx:2.4.0"
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ListDialog import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.list.TextFieldListItem import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -200,7 +201,7 @@ private fun EditLinkDialog(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TextComposerLinkDialogCreateLinkPreview() { internal fun TextComposerLinkDialogCreateLinkPreview() = ElementPreview {
TextComposerLinkDialog( TextComposerLinkDialog(
onDismissRequest = {}, onDismissRequest = {},
linkAction = LinkAction.InsertLink, linkAction = LinkAction.InsertLink,
@ -212,7 +213,7 @@ internal fun TextComposerLinkDialogCreateLinkPreview() {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TextComposerLinkDialogCreateLinkWithoutTextPreview() { internal fun TextComposerLinkDialogCreateLinkWithoutTextPreview() = ElementPreview {
TextComposerLinkDialog( TextComposerLinkDialog(
onDismissRequest = {}, onDismissRequest = {},
linkAction = LinkAction.SetLink(null), linkAction = LinkAction.SetLink(null),
@ -224,7 +225,7 @@ internal fun TextComposerLinkDialogCreateLinkWithoutTextPreview() {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TextComposerLinkDialogEditLinkPreview() { internal fun TextComposerLinkDialogEditLinkPreview() = ElementPreview {
TextComposerLinkDialog( TextComposerLinkDialog(
onDismissRequest = {}, onDismissRequest = {},
linkAction = LinkAction.SetLink("https://element.io"), linkAction = LinkAction.SetLink("https://element.io"),

View file

@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
@ -46,9 +47,7 @@ internal fun RecordingProgress(
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
) )
.padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp) .padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp)
.heightIn(26.dp) .heightIn(26.dp),
,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( Box(
@ -69,6 +68,6 @@ internal fun RecordingProgress(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun RecordingProgressPreview() { internal fun RecordingProgressPreview() = ElementPreview {
RecordingProgress() RecordingProgress()
} }

View file

@ -35,4 +35,26 @@ class KonsistPreviewTest {
it.hasNameEndingWith("DarkPreview").not() it.hasNameEndingWith("DarkPreview").not()
} }
} }
@Test
fun `Functions with '@PreviewsDayNight' annotation should contain 'ElementPreview' composable`() {
Konsist
.scopeFromProject()
.functions()
.withAllAnnotationsOf(PreviewsDayNight::class)
.assertTrue {
it.text.contains("ElementPreview")
}
}
@Test
fun `Functions with '@PreviewsDayNight' are internal`() {
Konsist
.scopeFromProject()
.functions()
.withAllAnnotationsOf(PreviewsDayNight::class)
.assertTrue {
it.hasInternalModifier
}
}
} }

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f19d53c688e3f862894775756f00040adb5cbba99de71c053ed503c1b8af9518
size 15239

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:11c145595f7713bc7b66f9d07e917bca82d6e06a1b55367801eb4d2cbfef89b0
size 14353

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d58d2d25d8f2c07c976bcda0eea7ec101f993ad1ef733fae4c713a67650e33e2
size 8498

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cbb48906ee7f1f18034cd7c64766790689f95f4ea4c0310a98430921b3792aee
size 30854

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ec32849337493132ab88ebc7270728e3c568896e40b0f451b633c924591cbaa
size 28579

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c94f10441359bdb901ee1f8be9c307312586d6ca54495e88ec231c1e931eb36b
size 38994

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:38456a511fbc72e3d544c8f88138be4f24b6adb3a10629989c037141213a9e19
size 39441

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:083c3972e5d019ce1d8565a655bd6f6fb6689e17792ddf7ed282d5eee529a7b6
size 40292

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bda49a721fbd19b4cbe0f4dc1b4013154a55e17f78e099f411d1caaef82cfa6c
size 46711

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:28d2e1634cc0bbf01f6efc2b260625abb26f2105bf1e858f2bd196830d70854c
size 44278

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ab8c04689414b5183fa6ab5a479063b478b3d02dfb6bf2df72b1b606c77392ad
size 36278

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fda961a2240c1458ad561c8f4e7fa265bbe6c70cf9b0fbfcd1ae0879036a3343
size 36714

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0cb889338b21dd8e1a913348ff02cd217f48840c4d54b99ed4d89b67dff771bb
size 37254

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7b0011f773767d2806b2269281415f910b4770bc0e95dff2a2a29a2c46f9e458
size 41488

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d50c34cdf50881d8176d90617c717f7214faeaa4fdce22ec4b93b0e5669b9869
size 39073