Browse Source

Merge pull request #1624 from vector-im/feature/fga/pin_auth_ui

PIN : unlock screen ui
pull/1629/head
ganfra 11 months ago committed by GitHub
parent
commit
005e5cc1a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 35
      appconfig/src/main/kotlin/io/element/android/appconfig/LockScreenConfig.kt
  2. 3
      build.gradle.kts
  3. 6
      features/lockscreen/impl/build.gradle.kts
  4. 18
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt
  5. 43
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/auth/PinAuthenticationPresenter.kt
  6. 30
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/auth/PinAuthenticationStateProvider.kt
  7. 84
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/auth/PinAuthenticationView.kt
  8. 117
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt
  9. 61
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinStateProvider.kt
  10. 2
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinDigit.kt
  11. 30
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntry.kt
  12. 8
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinEvents.kt
  13. 8
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinNode.kt
  14. 57
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinPresenter.kt
  15. 13
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinState.kt
  16. 61
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinStateProvider.kt
  17. 127
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinView.kt
  18. 20
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/validation/PinValidator.kt
  19. 7
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/validation/SetupPinFailure.kt
  20. 5
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/state/DefaultLockScreenStateService.kt
  21. 25
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt
  22. 8
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt
  23. 86
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt
  24. 29
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt
  25. 44
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt
  26. 270
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt
  27. 214
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
  28. 12
      features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadModel.kt
  29. 15
      features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryAssertions.kt
  30. 57
      features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinPresenterTest.kt
  31. 89
      features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt
  32. 3
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt
  33. 7
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt
  34. 7
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/RecordingProgress.kt
  35. 22
      tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt
  36. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.auth_null_PinAuthenticationView-D-0_0_null_0,NEXUS_5,1.0,en].png
  37. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.auth_null_PinAuthenticationView-N-0_1_null_0,NEXUS_5,1.0,en].png
  38. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.components_null_PinEntryTextField-D-0_0_null,NEXUS_5,1.0,en].png
  39. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.components_null_PinEntryTextField-N-0_1_null,NEXUS_5,1.0,en].png
  40. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-D-1_1_null_0,NEXUS_5,1.0,en].png
  41. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-D-1_1_null_1,NEXUS_5,1.0,en].png
  42. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-D-1_1_null_2,NEXUS_5,1.0,en].png
  43. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-D-1_1_null_3,NEXUS_5,1.0,en].png
  44. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-D-1_1_null_4,NEXUS_5,1.0,en].png
  45. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-N-1_2_null_0,NEXUS_5,1.0,en].png
  46. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-N-1_2_null_1,NEXUS_5,1.0,en].png
  47. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-N-1_2_null_2,NEXUS_5,1.0,en].png
  48. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-N-1_2_null_3,NEXUS_5,1.0,en].png
  49. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-N-1_2_null_4,NEXUS_5,1.0,en].png
  50. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock.keypad_null_PinKeypad-D-3_3_null,NEXUS_5,1.0,en].png
  51. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock.keypad_null_PinKeypad-N-3_4_null,NEXUS_5,1.0,en].png
  52. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-D-2_2_null_0,NEXUS_5,1.0,en].png
  53. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-D-2_2_null_1,NEXUS_5,1.0,en].png
  54. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-D-2_2_null_2,NEXUS_5,1.0,en].png
  55. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-D-2_2_null_3,NEXUS_5,1.0,en].png
  56. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-D-2_2_null_4,NEXUS_5,1.0,en].png
  57. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-N-2_3_null_0,NEXUS_5,1.0,en].png
  58. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-N-2_3_null_1,NEXUS_5,1.0,en].png
  59. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-N-2_3_null_2,NEXUS_5,1.0,en].png
  60. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-N-2_3_null_3,NEXUS_5,1.0,en].png
  61. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-N-2_3_null_4,NEXUS_5,1.0,en].png

35
appconfig/src/main/kotlin/io/element/android/appconfig/LockScreenConfig.kt

@ -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
}

3
build.gradle.kts

@ -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

6
features/lockscreen/impl/build.gradle.kts

@ -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)
} }

18
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt

@ -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)
} }
} }
} }

43
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/auth/PinAuthenticationPresenter.kt

@ -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
)
}
}

30
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/auth/PinAuthenticationStateProvider.kt

@ -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 = {}
)

84
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/auth/PinAuthenticationView.kt

@ -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,
)
}
}

117
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt

@ -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 = {},
)
}
}

61
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinStateProvider.kt

@ -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 = {}
)

2
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/model/PinDigit.kt → features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinDigit.kt

@ -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

30
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/model/PinEntry.kt → features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntry.kt

@ -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 deleteLast(): PinEntry {
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 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 { fun clear(): PinEntry {
return fillWith("") return createEmpty(size)
} }
fun isPinComplete(): Boolean { 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()

8
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/validation/CreatePinFailure.kt → features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinEvents.kt

@ -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
} }

8
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinNode.kt → features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinNode.kt

@ -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

57
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinPresenter.kt → features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinPresenter.kt

@ -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
) )

13
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinState.kt → features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinState.kt

@ -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 {

61
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinStateProvider.kt

@ -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 = {}
)

127
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinView.kt → features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinView.kt

@ -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 = {},
) )

20
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/validation/PinValidator.kt → features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/validation/PinValidator.kt

@ -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
} }

7
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/auth/PinAuthenticationEvents.kt → features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/validation/SetupPinFailure.kt

@ -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
} }

5
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/state/DefaultLockScreenStateService.kt

@ -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
} }
} }

25
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt

@ -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
}

8
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/auth/PinAuthenticationNode.kt → features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt

@ -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
) )

86
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt

@ -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
}
}
}

29
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt

@ -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
}

44
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt

@ -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 = {}
)

270
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt

@ -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,
)
}
}

214
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt

@ -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 = {}
)
}
}
}

12
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinEvents.kt → features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadModel.kt

@ -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
} }

15
features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/auth/PinAuthenticationState.kt → features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryAssertions.kt

@ -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()
}

57
features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/create/CreatePinPresenterTest.kt → features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/SetupPinPresenterTest.kt

@ -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())
} }
} }

89
features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt

@ -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,
)
}
}

3
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt

@ -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()
} }
} }

7
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt

@ -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"),

7
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/RecordingProgress.kt

@ -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()
} }

22
tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt

@ -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
}
}
} }

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.auth_null_PinAuthenticationView-D-0_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.auth_null_PinAuthenticationView-N-0_1_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.components_null_PinEntryTextField-D-0_0_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.components_null_PinEntryTextField-N-0_1_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-D-1_1_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-D-1_1_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-D-1_1_null_2,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_3,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-D-1_1_null_3,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-D-1_1_null_4,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-D-1_1_null_4,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-N-1_2_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-N-1_2_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-N-1_2_null_2,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_3,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-N-1_2_null_3,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.create_null_CreatePinView-N-1_2_null_4,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.setup_null_SetupPinView-N-1_2_null_4,NEXUS_5,1.0,en].png

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock.keypad_null_PinKeypad-D-3_3_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock.keypad_null_PinKeypad-N-3_4_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-D-2_2_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-D-2_2_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-D-2_2_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-D-2_2_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-D-2_2_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-N-2_3_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-N-2_3_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-N-2_3_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-N-2_3_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.lockscreen.impl.unlock_null_PinUnlockView-N-2_3_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save