Create SetMessagePasswordActivity.kt

MAILAND-1672, MAILAND-1671
This commit is contained in:
Davide Farella 2021-05-28 11:46:08 +02:00 committed by Davide Giuseppe Farella
parent 9b537b1e5e
commit df320c0ce2
16 changed files with 1017 additions and 24 deletions

View File

@ -26,13 +26,11 @@ import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
import androidx.test.espresso.matcher.ViewMatchers.assertThat
import androidx.test.espresso.matcher.ViewMatchers.withClassName
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.rules.activityScenarioRule
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import ch.protonmail.android.R
import com.google.android.material.textfield.TextInputEditText
import org.hamcrest.Matchers.`is`
import ch.protonmail.android.util.withTextInputEditTextId
import org.hamcrest.core.AllOf
import org.junit.Rule
import org.junit.runner.RunWith
@ -164,19 +162,11 @@ class SetMessageExpirationActivityTest {
@Suppress("SameParameterValue")
private fun setCustomDaysAndHours(days: Int, hours: Int) {
onView(
AllOf.allOf(
withId(R.id.days_and_hours_picker_days_input),
withClassName(`is`(TextInputEditText::class.qualifiedName))
)
).perform(replaceText(days.toString()))
onView(withTextInputEditTextId(R.id.days_and_hours_picker_days_input))
.perform(replaceText(days.toString()))
onView(
AllOf.allOf(
withId(R.id.days_and_hours_picker_hours_input),
withClassName(`is`(TextInputEditText::class.qualifiedName))
)
).perform(replaceText(hours.toString()))
onView(withTextInputEditTextId(R.id.days_and_hours_picker_hours_input))
.perform(replaceText(hours.toString()))
}
private fun performSetClick(): ViewInteraction =

View File

@ -0,0 +1,130 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.compose.presentation.ui
import android.content.Intent
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.ViewInteraction
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import ch.protonmail.android.R
import ch.protonmail.android.util.withTextInputEditTextId
import org.hamcrest.core.AllOf.allOf
import org.junit.runner.RunWith
import kotlin.test.BeforeTest
import kotlin.test.Test
/**
* Test suite for [SetMessagePasswordActivity]
*/
@RunWith(AndroidJUnit4ClassRunner::class)
@Suppress("SameParameterValue")
class SetMessagePasswordActivityTest {
private val baseIntent =
Intent(ApplicationProvider.getApplicationContext(), SetMessagePasswordActivity::class.java)
@BeforeTest
fun setup() {
Intents.init()
}
@Test
fun inputIsSetCorrectly() {
// given
val expectedPassword = "12345"
val expectedHint = "hint"
// when
val intent = baseIntent
.putExtra(ARG_SET_MESSAGE_PASSWORD_PASSWORD, expectedPassword)
.putExtra(ARG_SET_MESSAGE_PASSWORD_HINT, expectedHint)
ActivityScenario.launch<SetMessagePasswordActivity>(intent)
// then
onPasswordView().check(matches(withText(expectedPassword)))
onRepeatView().check(matches(withText(expectedPassword)))
onHintView().check(matches(withText(expectedHint)))
}
@Test
fun resultIsSetCorrectly() {
// given
val expectedPassword = "12345"
val expectedHint = "hint"
// when
val scenario = ActivityScenario.launch<SetMessagePasswordActivity>(baseIntent)
setPassword(expectedPassword)
setHint(expectedHint)
performApplyClick()
// then
assertResult(scenario, expectedPassword, expectedHint)
}
private fun onPasswordView(): ViewInteraction =
onView(withTextInputEditTextId(R.id.set_msg_password_msg_password_input))
private fun onRepeatView(): ViewInteraction =
onView(withTextInputEditTextId(R.id.set_msg_password_repeat_password_input))
private fun onHintView(): ViewInteraction =
onView(withTextInputEditTextId(R.id.set_msg_password_hint_input))
private fun setPassword(password: String) {
onPasswordView().perform(replaceText(password))
onRepeatView().perform(replaceText(password))
}
private fun setHint(hint: String) {
onHintView().perform(replaceText(hint))
}
private fun performApplyClick() {
onView(withId(R.id.set_msg_password_apply_button)).perform(click())
}
private fun assertResult(
scenario: ActivityScenario<SetMessagePasswordActivity>,
expectedPassword: String,
expectedHint: String
) {
val resultIntent = scenario.result.resultData
ViewMatchers.assertThat(
resultIntent,
allOf(
hasExtra(ARG_SET_MESSAGE_PASSWORD_PASSWORD, expectedPassword),
hasExtra(ARG_SET_MESSAGE_PASSWORD_HINT, expectedHint)
)
)
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.util
import android.view.View
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withId
import com.google.android.material.textfield.TextInputEditText
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.hamcrest.core.AllOf.allOf
/**
* Matches a view with given [id], which is a [TextInputEditText]
* @see withId
*/
fun withTextInputEditTextId(id: Int): Matcher<View> =
allOf(
withId(id),
ViewMatchers.withClassName(Matchers.`is`(TextInputEditText::class.qualifiedName))
)

View File

@ -213,6 +213,7 @@
</intent-filter>
</activity>
<activity android:name=".compose.presentation.ui.SetMessageExpirationActivity"/>
<activity android:name=".compose.presentation.ui.SetMessagePasswordActivity"/>
<activity
android:name=".activities.SearchActivity"
android:exported="false"

View File

@ -33,6 +33,11 @@ sealed class ComposeMessageEventUiModel {
*/
data class OnAttachmentsChange(val attachments: List<ComposerAttachmentUiModel>) : ComposeMessageEventUiModel()
/**
* Password change has been requested and previous password is ready
*/
data class OnPasswordChangeRequest(val currentPassword: MessagePasswordUiModel) : ComposeMessageEventUiModel()
/**
* Expiration change has been requested and previous expiration is ready
*/

View File

@ -0,0 +1,30 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.compose.presentation.model
sealed class MessagePasswordUiModel {
data class Set(
val password: String,
val hint: String?
) : MessagePasswordUiModel()
object Unset : MessagePasswordUiModel()
}

View File

@ -0,0 +1,56 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.compose.presentation.model
import ch.protonmail.android.compose.presentation.ui.SetMessagePasswordActivity
import me.proton.core.util.kotlin.EMPTY_STRING
import me.proton.core.util.kotlin.any
/**
* Ui model for [SetMessagePasswordActivity]
*/
data class SetMessagePasswordUiModel(
val passwordInput: Input,
val repeatInput: Input,
val messagePassword: MessagePasswordUiModel,
val hasErrors: Boolean = any(passwordInput, repeatInput) { it.error != null }
) {
data class Input(
val text: String,
val error: Error?
)
sealed class Error {
object Empty : Error()
object TooShort : Error()
object TooLong : Error()
object DoesNotMatch : Error()
}
companion object {
val Empty = SetMessagePasswordUiModel(
passwordInput = Input(EMPTY_STRING, Error.Empty),
repeatInput = Input(EMPTY_STRING, Error.Empty),
messagePassword = MessagePasswordUiModel.Unset
)
}
}

View File

@ -30,8 +30,12 @@ import ch.protonmail.android.activities.BaseContactsActivity
import ch.protonmail.android.attachments.domain.model.UriPair
import ch.protonmail.android.attachments.presentation.model.FilePickerMask
import ch.protonmail.android.compose.ComposeMessageViewModel
import ch.protonmail.android.compose.presentation.model.ComposeMessageEventUiModel
import ch.protonmail.android.compose.presentation.model.ComposeMessageEventUiModel.OnAttachmentsChange
import ch.protonmail.android.compose.presentation.model.ComposeMessageEventUiModel.OnExpirationChangeRequest
import ch.protonmail.android.compose.presentation.model.ComposeMessageEventUiModel.OnPasswordChangeRequest
import ch.protonmail.android.compose.presentation.model.ComposeMessageEventUiModel.OnPhotoUriReady
import ch.protonmail.android.compose.presentation.model.ComposerAttachmentUiModel
import ch.protonmail.android.compose.presentation.model.MessagePasswordUiModel
import ch.protonmail.android.databinding.ActivityComposeMessage2Binding
import ch.protonmail.android.ui.actionsheet.AddAttachmentsActionSheet
import ch.protonmail.android.ui.actionsheet.AddAttachmentsActionSheet.Action
@ -52,6 +56,13 @@ abstract class ComposeMessageKotlinActivity : BaseContactsActivity() {
protected lateinit var binding: ActivityComposeMessage2Binding
// region activity results
// region password
private val setPasswordLauncher =
registerForActivityResult(SetMessagePasswordActivity.ResultContract()) { messagePassword ->
// TODO set message password
}
// endregion
// region expiration
private val setExpirationLauncher =
registerForActivityResult(SetMessageExpirationActivity.ResultContract()) { daysHoursPair ->
@ -88,6 +99,9 @@ abstract class ComposeMessageKotlinActivity : BaseContactsActivity() {
// region setup UI
binding.composerBottomAppBar.apply {
onPasswordClick {
// TODO ask current expiration to ViewModel
}
onExpirationClick {
// TODO ask current expiration to ViewModel
}
@ -101,12 +115,10 @@ abstract class ComposeMessageKotlinActivity : BaseContactsActivity() {
composeViewModel.events
.onEach { event ->
when (event) {
is ComposeMessageEventUiModel.OnAttachmentsChange ->
onAttachmentsChanged(event.attachments)
is ComposeMessageEventUiModel.OnExpirationChangeRequest ->
openSetExpiration(event.currentExpiration)
is ComposeMessageEventUiModel.OnPhotoUriReady ->
takePhotoFromCamera(event.uri)
is OnAttachmentsChange -> onAttachmentsChanged(event.attachments)
is OnPasswordChangeRequest -> openSetPassword(event.currentPassword)
is OnExpirationChangeRequest -> openSetExpiration(event.currentExpiration)
is OnPhotoUriReady -> takePhotoFromCamera(event.uri)
}
}.launchIn(lifecycleScope)
@ -121,6 +133,12 @@ abstract class ComposeMessageKotlinActivity : BaseContactsActivity() {
// endregion
}
// region password
private fun openSetPassword(currentPassword: MessagePasswordUiModel) {
setPasswordLauncher.launch(currentPassword)
}
// endregion
// region expiration
private fun openSetExpiration(currentExpiration: DaysHoursPair) {
setExpirationLauncher.launch(currentExpiration)

View File

@ -31,6 +31,7 @@ import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import ch.protonmail.android.R
import ch.protonmail.android.compose.presentation.ui.SetMessageExpirationActivity.Expiration.Custom
import ch.protonmail.android.compose.presentation.ui.SetMessageExpirationActivity.Expiration.None
@ -40,6 +41,8 @@ import ch.protonmail.android.compose.presentation.ui.SetMessageExpirationActivit
import ch.protonmail.android.compose.presentation.ui.SetMessageExpirationActivity.Expiration.ThreeDays
import ch.protonmail.android.databinding.ActivitySetMessageExpirationBinding
import ch.protonmail.android.ui.view.DaysHoursPair
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import me.proton.core.presentation.utils.onClick
import timber.log.Timber
@ -88,6 +91,9 @@ class SetMessageExpirationActivity : AppCompatActivity() {
threeDaysEntry.first.onClick { setExpiration(ThreeDays) }
oneWeekEntry.first.onClick { setExpiration(OneWeek) }
customEntry.first.onClick { setExpiration(Custom(0, 0)) }
binding.setMsgExpirationPickerView.onChange
.onEach { setExpiration(Custom(it.days, it.hours)) }
.launchIn(lifecycleScope)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {

View File

@ -0,0 +1,192 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.compose.presentation.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import ch.protonmail.android.R
import ch.protonmail.android.compose.presentation.model.MessagePasswordUiModel
import ch.protonmail.android.compose.presentation.model.SetMessagePasswordUiModel
import ch.protonmail.android.compose.presentation.viewmodel.SetMessagePasswordViewModel
import ch.protonmail.android.databinding.ActivitySetMessagePasswordBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import me.proton.core.presentation.ui.view.ProtonInput
import me.proton.core.presentation.utils.onClick
import me.proton.core.presentation.utils.onTextChange
import timber.log.Timber
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
const val ARG_SET_MESSAGE_PASSWORD_PASSWORD = "arg.password"
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
const val ARG_SET_MESSAGE_PASSWORD_HINT = "arg.hint"
/**
* Activity for set a password for a given Message
* @see ComposeMessageKotlinActivity
*/
@AndroidEntryPoint
class SetMessagePasswordActivity : AppCompatActivity() {
private val viewModel: SetMessagePasswordViewModel by viewModels()
private val binding by lazy {
ActivitySetMessagePasswordBinding.inflate(layoutInflater)
}
private lateinit var messagePassword: MessagePasswordUiModel
override fun onCreate(savedInstanceState: Bundle?) {
setContentView(binding.root)
super.onCreate(savedInstanceState)
setSupportActionBar(binding.setMsgPasswordToolbar)
val password = intent.getStringExtra(ARG_SET_MESSAGE_PASSWORD_PASSWORD)
val hint = intent.getStringExtra(ARG_SET_MESSAGE_PASSWORD_HINT)
viewModel.validate(password, password, hint)
with(binding) {
listOf(
setMsgPasswordMsgPasswordInput,
setMsgPasswordRepeatPasswordInput,
setMsgPasswordHintInput
).forEach {
it.onTextChange { validateInput() }
}
setMsgPasswordApplyButton.onClick {
setResultAndFinish()
}
}
viewModel.result
.flowWithLifecycle(lifecycle)
.onEach(::updateUi)
.launchIn(lifecycleScope)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
}
private fun updateUi(model: SetMessagePasswordUiModel) {
messagePassword = model.messagePassword
setPasswordInput(model.passwordInput)
setRepeatInput(model.repeatInput)
setHintInput((messagePassword as? MessagePasswordUiModel.Set)?.hint)
setApplyButton(model.hasErrors)
}
private fun setPasswordInput(input: SetMessagePasswordUiModel.Input) {
binding.setMsgPasswordMsgPasswordInput.apply {
setTextIfChanged(input.text)
getErrorString(input.error)
?.let(::setInputError)
?: clearInputError()
}
}
private fun setRepeatInput(input: SetMessagePasswordUiModel.Input) {
binding.setMsgPasswordRepeatPasswordInput.apply {
setTextIfChanged(input.text)
getErrorString(input.error)
?.let(::setInputError)
?: clearInputError()
}
}
private fun setHintInput(hint: String?) {
binding.setMsgPasswordHintInput.setTextIfChanged(hint)
}
private fun setApplyButton(hasErrors: Boolean) {
binding.setMsgPasswordApplyButton.isEnabled = hasErrors.not()
}
private fun setResultAndFinish() {
Timber.v("Set password $messagePassword")
val messagePassword = messagePassword
val resultIntent = Intent().apply {
if (messagePassword is MessagePasswordUiModel.Set) {
putExtra(ARG_SET_MESSAGE_PASSWORD_PASSWORD, messagePassword.password)
putExtra(ARG_SET_MESSAGE_PASSWORD_HINT, messagePassword.hint)
}
}
setResult(Activity.RESULT_OK, resultIntent)
finish()
}
private fun getErrorString(error: SetMessagePasswordUiModel.Error?): String? {
return when (error) {
SetMessagePasswordUiModel.Error.TooShort -> getString(R.string.set_msg_password_error_too_short)
SetMessagePasswordUiModel.Error.TooLong -> getString(R.string.set_msg_password_error_too_long)
SetMessagePasswordUiModel.Error.DoesNotMatch -> getString(R.string.set_msg_password_error_mismatch)
SetMessagePasswordUiModel.Error.Empty, null -> null
}
}
private fun validateInput() {
with(binding) {
val password = setMsgPasswordMsgPasswordInput.text
val repeat = setMsgPasswordRepeatPasswordInput.text
val hint = setMsgPasswordHintInput.text
viewModel.validate(password, repeat, hint)
}
}
private fun ProtonInput.setTextIfChanged(charSequence: CharSequence?) {
if (text.toString() != charSequence.toString()) {
text = charSequence
}
}
class ResultContract : ActivityResultContract<MessagePasswordUiModel, MessagePasswordUiModel>() {
private lateinit var input: MessagePasswordUiModel
override fun createIntent(context: Context, input: MessagePasswordUiModel): Intent {
this.input = input
return Intent(context, SetMessagePasswordActivity::class.java).apply {
if (input is MessagePasswordUiModel.Set) {
putExtra(ARG_SET_MESSAGE_PASSWORD_PASSWORD, input.password)
putExtra(ARG_SET_MESSAGE_PASSWORD_HINT, input.hint)
}
}
}
override fun parseResult(resultCode: Int, intent: Intent?): MessagePasswordUiModel {
return intent?.let {
val password = intent.getStringExtra(ARG_SET_MESSAGE_PASSWORD_PASSWORD)
val hint = intent.getStringExtra(ARG_SET_MESSAGE_PASSWORD_HINT)
if (password != null) {
MessagePasswordUiModel.Set(password, hint)
} else {
MessagePasswordUiModel.Unset
}
} ?: input
}
}
}

View File

@ -0,0 +1,98 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.compose.presentation.viewmodel
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.PRIVATE
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import ch.protonmail.android.compose.presentation.model.MessagePasswordUiModel
import ch.protonmail.android.compose.presentation.model.SetMessagePasswordUiModel
import ch.protonmail.android.compose.presentation.model.SetMessagePasswordUiModel.Error
import ch.protonmail.android.compose.presentation.model.SetMessagePasswordUiModel.Input
import ch.protonmail.android.compose.presentation.ui.SetMessagePasswordActivity
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import me.proton.core.util.kotlin.DispatcherProvider
import me.proton.core.util.kotlin.EMPTY_STRING
import javax.inject.Inject
@VisibleForTesting(otherwise = PRIVATE)
const val MESSAGE_PASSWORD_MIN_LENGTH = 4
@VisibleForTesting(otherwise = PRIVATE)
const val MESSAGE_PASSWORD_MAX_LENGTH = 21
/**
* ViewModel for [SetMessagePasswordActivity]
*/
@HiltViewModel
class SetMessagePasswordViewModel @Inject constructor(
private val dispatchers: DispatcherProvider
) : ViewModel() {
private val _result: MutableStateFlow<SetMessagePasswordUiModel> =
MutableStateFlow(SetMessagePasswordUiModel.Empty)
val result: StateFlow<SetMessagePasswordUiModel> =
_result.asStateFlow()
fun validate(password: CharSequence?, repeat: CharSequence?, hint: CharSequence?) {
viewModelScope.launch(dispatchers.Comp) {
val passwordInput = validatePassword(password)
val repeatInput = validateRepeatPassword(password, repeat)
val hasErrors = passwordInput.error ?: repeatInput.error != null
val passwordUiModel = if (hasErrors) {
MessagePasswordUiModel.Unset
} else {
MessagePasswordUiModel.Set(
checkNotNull(password).toString(),
hint.toString()
)
}
_result.emit(SetMessagePasswordUiModel(passwordInput, repeatInput, passwordUiModel))
}
}
private fun validatePassword(password: CharSequence?): Input {
val error = when {
password.isNullOrBlank() -> Error.Empty
password.length < MESSAGE_PASSWORD_MIN_LENGTH -> Error.TooShort
password.length > MESSAGE_PASSWORD_MAX_LENGTH -> Error.TooLong
else -> null
}
return Input(password?.toString() ?: EMPTY_STRING, error)
}
private fun validateRepeatPassword(
password: CharSequence?,
repeat: CharSequence?
): Input {
val error = when {
repeat.isNullOrBlank() -> Error.Empty
repeat.toString() != password.toString() -> Error.DoesNotMatch
else -> null
}
return Input(repeat?.toString() ?: EMPTY_STRING, error)
}
}

View File

@ -69,13 +69,15 @@ class DaysAndHoursPickerView @JvmOverloads constructor(
daysInput.onTextChange { text ->
val hours = hoursInput.text?.toString()?.toIntOrNull()
if (normaliseDays(text) == HasChanged.False && hours != null)
if (normaliseDays(text) == HasChanged.False && hours != null) {
changesBuffer.offer(DaysHoursPair(text.toString().toInt(), hours))
}
}
hoursInput.onTextChange { text ->
val days = daysInput.text?.toString()?.toIntOrNull()
if (normaliseHours(text) == HasChanged.False && days != null)
if (normaliseHours(text) == HasChanged.False && days != null) {
changesBuffer.offer(DaysHoursPair(days, text.toString().toInt()))
}
}
}

View File

@ -0,0 +1,35 @@
<!--
~ Copyright (c) 2020 Proton Technologies AG
~
~ This file is part of ProtonMail.
~
~ ProtonMail is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ ProtonMail is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with ProtonMail. If not, see https://www.gnu.org/licenses/.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12.75,16.5V9H9.75V10.5H11.25V16.5H9.75V18H14.25V16.5H12.75Z"
android:fillColor="@color/icon_norm"/>
<path
android:pathData="M12.75,7.5V6H11.25V7.5H12.75Z"
android:fillColor="@color/icon_norm"/>
<path
android:pathData="M1.5,12C1.5,6.201 6.201,1.5 12,1.5C14.7848,1.5 17.4555,2.6063 19.4246,4.5754C21.3938,6.5445 22.5,9.2152 22.5,12C22.5,17.799 17.799,22.5 12,22.5C6.201,22.5 1.5,17.799 1.5,12ZM3,12C3,16.9706 7.0294,21 12,21C14.3869,21 16.6761,20.0518 18.364,18.364C20.0518,16.6761 21,14.3869 21,12C21,7.0294 16.9706,3 12,3C7.0294,3 3,7.0294 3,12Z"
android:fillColor="@color/icon_norm"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2020 Proton Technologies AG
~
~ This file is part of ProtonMail.
~
~ ProtonMail is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ ProtonMail is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with ProtonMail. If not, see https://www.gnu.org/licenses/.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- Toolbar -->
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/set_msg_password_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/set_msg_password_title" />
<!-- Body -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/padding_l"
android:paddingVertical="@dimen/padding_xl">
<!-- Info -->
<ImageView
android:id="@+id/set_msg_password_info_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_xs"
android:contentDescription="@string/set_msg_password_info_icon_description"
android:src="@drawable/ic_info_circle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/set_msg_password_info_text_view"
app:tint="@color/shade_80" />
<TextView
android:id="@+id/set_msg_password_info_text_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_m"
android:autoLink="web"
android:text="@string/set_msg_password_info"
android:textAppearance="@style/Proton.Text.DefaultSmall.Weak"
android:textColor="@color/text_weak"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/set_msg_password_info_icon"
app:layout_constraintTop_toTopOf="parent" />
<!-- Message password -->
<me.proton.core.presentation.ui.view.ProtonInput
android:id="@+id/set_msg_password_msg_password_input"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_xl"
android:hint="@string/set_msg_password_msg_password_hint"
android:inputType="textPassword"
app:actionMode="password_toggle"
app:label="@string/set_msg_password_msg_password_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/set_msg_password_info_text_view" />
<!-- Repeat password -->
<me.proton.core.presentation.ui.view.ProtonInput
android:id="@+id/set_msg_password_repeat_password_input"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_m"
android:hint="@string/set_msg_password_repeat_password_hint"
android:inputType="textPassword"
app:actionMode="password_toggle"
app:label="@string/set_msg_password_repeat_password_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/set_msg_password_msg_password_input" />
<!-- Hint -->
<me.proton.core.presentation.ui.view.ProtonInput
android:id="@+id/set_msg_password_hint_input"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_m"
android:hint="@string/set_msg_password_password_hint_hint"
android:inputType="text"
app:actionMode="clear_text"
app:label="@string/set_msg_password_password_hint_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/set_msg_password_repeat_password_input" />
<!-- Apply -->
<me.proton.core.presentation.ui.view.ProtonButton
android:id="@+id/set_msg_password_apply_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/set_msg_password_hint_input"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="@dimen/padding_m"
android:text="@string/set_msg_password_apply_password"
android:enabled="false"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</LinearLayout>

View File

@ -58,6 +58,9 @@
<string name="set_msg_password_password_hint_title">Password hint</string>
<string name="set_msg_password_password_hint_hint">Define hint (Optional)</string>
<string name="set_msg_password_apply_password">Apply password</string>
<string name="set_msg_password_error_too_short">Password too short</string>
<string name="set_msg_password_error_too_long">Password too long</string>
<string name="set_msg_password_error_mismatch">Password does not match</string>
<string name="set_msg_expiration_title">Message expiration</string>
<string name="set_msg_expiration_none">None</string>

View File

@ -0,0 +1,261 @@
/*
* Copyright (c) 2020 Proton Technologies AG
*
* This file is part of ProtonMail.
*
* ProtonMail is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonMail is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonMail. If not, see https://www.gnu.org/licenses/.
*/
package ch.protonmail.android.compose.presentation.viewmodel
import ch.protonmail.android.compose.presentation.model.MessagePasswordUiModel
import ch.protonmail.android.compose.presentation.model.SetMessagePasswordUiModel
import ch.protonmail.android.compose.presentation.model.SetMessagePasswordUiModel.Error
import ch.protonmail.android.compose.presentation.model.SetMessagePasswordUiModel.Input
import kotlinx.coroutines.flow.first
import me.proton.core.test.kotlin.CoroutinesTest
import me.proton.core.util.kotlin.EMPTY_STRING
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Test suite for [SetMessagePasswordViewModel]
*/
class SetMessagePasswordViewModelTest : CoroutinesTest {
private val viewModel = SetMessagePasswordViewModel(dispatchers)
private val testHint = "hint"
@Test
fun emptyPasswordAndEmptyRepeat() = coroutinesTest {
// given
val password = EMPTY_STRING
val repeat = EMPTY_STRING
val expected = SetMessagePasswordUiModel(
passwordInput = Input(password, Error.Empty),
repeatInput = Input(repeat, Error.Empty),
hasErrors = true,
messagePassword = MessagePasswordUiModel.Unset
)
// when
viewModel.validate(
password = password,
repeat = repeat,
hint = testHint
)
val result = viewModel.result.first()
// then
assertEquals(expected, result)
}
@Test
fun emptyPasswordAnd5CharsRepeat() = coroutinesTest {
// given
val password = EMPTY_STRING
val repeat = "hello"
val expected = SetMessagePasswordUiModel(
passwordInput = Input(password, Error.Empty),
repeatInput = Input(repeat, Error.DoesNotMatch),
hasErrors = true,
messagePassword = MessagePasswordUiModel.Unset
)
// when
viewModel.validate(
password = password,
repeat = repeat,
hint = testHint
)
val result = viewModel.result.first()
// then
assertEquals(expected, result)
}
@Test
fun shortPasswordAndEmptyRepeat() = coroutinesTest {
// given
val password = "123"
val repeat = EMPTY_STRING
val expected = SetMessagePasswordUiModel(
passwordInput = Input(password, Error.TooShort),
repeatInput = Input(repeat, Error.Empty),
hasErrors = true,
messagePassword = MessagePasswordUiModel.Unset
)
// when
viewModel.validate(
password = password,
repeat = repeat,
hint = testHint
)
val result = viewModel.result.first()
// then
assertEquals(expected, result)
}
@Test
fun shortPasswordAndNotMatchRepeat() = coroutinesTest {
// given
val password = "123"
val repeat = "1234"
val expected = SetMessagePasswordUiModel(
passwordInput = Input(password, Error.TooShort),
repeatInput = Input(repeat, Error.DoesNotMatch),
hasErrors = true,
messagePassword = MessagePasswordUiModel.Unset
)
// when
viewModel.validate(
password = password,
repeat = repeat,
hint = testHint
)
val result = viewModel.result.first()
// then
assertEquals(expected, result)
}
@Test
fun shortPasswordAndMatchRepeat() = coroutinesTest {
// given
val password = "123"
val expected = SetMessagePasswordUiModel(
passwordInput = Input(password, Error.TooShort),
repeatInput = Input(password, null),
hasErrors = true,
messagePassword = MessagePasswordUiModel.Unset
)
// when
viewModel.validate(
password = password,
repeat = password,
hint = testHint
)
val result = viewModel.result.first()
// then
assertEquals(expected, result)
}
@Test
fun longPasswordAndNotMatchRepeat() = coroutinesTest {
// given
val password = (0..50).joinToString { it.toString() }
val repeat = "1234"
val expected = SetMessagePasswordUiModel(
passwordInput = Input(password, Error.TooLong),
repeatInput = Input(repeat, Error.DoesNotMatch),
hasErrors = true,
messagePassword = MessagePasswordUiModel.Unset
)
// when
viewModel.validate(
password = password,
repeat = repeat,
hint = testHint
)
val result = viewModel.result.first()
// then
assertEquals(expected, result)
}
@Test
fun longPasswordAndMatchRepeat() = coroutinesTest {
// given
val password = (0..50).joinToString { it.toString() }
val expected = SetMessagePasswordUiModel(
passwordInput = Input(password, Error.TooLong),
repeatInput = Input(password, null),
hasErrors = true,
messagePassword = MessagePasswordUiModel.Unset
)
// when
viewModel.validate(
password = password,
repeat = password,
hint = testHint
)
val result = viewModel.result.first()
// then
assertEquals(expected, result)
}
@Test
fun okPasswordAndNotMatchRepeat() = coroutinesTest {
// given
val password = "12345"
val repeat = "1234"
val expected = SetMessagePasswordUiModel(
passwordInput = Input(password, null),
repeatInput = Input(repeat, Error.DoesNotMatch),
hasErrors = true,
messagePassword = MessagePasswordUiModel.Unset
)
// when
viewModel.validate(
password = password,
repeat = repeat,
hint = testHint
)
val result = viewModel.result.first()
// then
assertEquals(expected, result)
}
@Test
fun okPasswordAndMatchRepeat() = coroutinesTest {
// given
val password = "12345"
val expected = SetMessagePasswordUiModel(
passwordInput = Input(password, null),
repeatInput = Input(password, null),
hasErrors = false,
messagePassword = MessagePasswordUiModel.Set(password, testHint)
)
// when
viewModel.validate(
password = password,
repeat = password,
hint = testHint
)
val result = viewModel.result.first()
// then
assertEquals(expected, result)
}
}