Merge remote-tracking branch 'upstream/main' into integrate-v6-changes

This commit is contained in:
Marino Meneghel
2024-10-28 12:38:39 +01:00
526 changed files with 15066 additions and 3391 deletions
+1
View File
@@ -74,6 +74,7 @@ scripts/uitests/AssetsFile.lock.bak
.idea/androidTestResultsUserPreferences.xml
.idea/assetWizardSettings.xml
.idea/deploymentTargetDropDown.xml
.idea/deploymentTargetSelector.xml
.idea/vcs.xml
.idea/**/shelf
.idea/runConfigurations.xml
+1 -1
View File
@@ -1,4 +1,4 @@
{
"project": "android-mail-new",
"locale": "e45c98b7fa1f33494847c289b8f821e418aa9608"
"locale": "bee86db926775541bb26d476feaf29f7d7e3d418"
}
-1
View File
@@ -282,7 +282,6 @@ dependencies {
androidTestImplementation(Dependencies.androidTestLibs)
androidTestImplementation(Proton.Core.accountManagerPresentationCompose)
androidTestImplementation(Proton.Core.accountRecoveryTest)
androidTestImplementation(Proton.Core.authTest)
androidTestImplementation(Proton.Core.planTest)
androidTestImplementation(Proton.Core.reportTest)
androidTestImplementation(Proton.Core.userRecoveryTest)
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 36,
"identityHash": "0a78184b5759a779486e235f8750efcd",
"identityHash": "612b7eef8403ea1fad620c8f4b548cde",
"entities": [
{
"tableName": "AccountEntity",
@@ -323,6 +323,172 @@
}
]
},
{
"tableName": "AuthDeviceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deviceId",
"columnName": "deviceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "addressId",
"columnName": "addressId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "localizedClientName",
"columnName": "localizedClientName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAtUtcSeconds",
"columnName": "createdAtUtcSeconds",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activatedAtUtcSeconds",
"columnName": "activatedAtUtcSeconds",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "rejectedAtUtcSeconds",
"columnName": "rejectedAtUtcSeconds",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "activationToken",
"columnName": "activationToken",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastActivityAtUtcSeconds",
"columnName": "lastActivityAtUtcSeconds",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"deviceId"
]
},
"indices": [
{
"name": "index_AuthDeviceEntity_userId",
"unique": false,
"columnNames": [
"userId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)"
},
{
"name": "index_AuthDeviceEntity_addressId",
"unique": false,
"columnNames": [
"addressId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)"
}
],
"foreignKeys": [
{
"table": "AccountEntity",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"userId"
],
"referencedColumns": [
"userId"
]
}
]
},
{
"tableName": "DeviceSecretEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `secret` TEXT NOT NULL, `token` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "secret",
"columnName": "secret",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId"
]
},
"indices": [
{
"name": "index_DeviceSecretEntity_userId",
"unique": false,
"columnNames": [
"userId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceSecretEntity_userId` ON `${TABLE_NAME}` (`userId`)"
}
],
"foreignKeys": [
{
"table": "AccountEntity",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"userId"
],
"referencedColumns": [
"userId"
]
}
]
},
{
"tableName": "UserEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
@@ -2765,7 +2931,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0a78184b5759a779486e235f8750efcd')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '612b7eef8403ea1fad620c8f4b548cde')"
]
}
}
File diff suppressed because it is too large Load Diff
@@ -173,7 +173,7 @@ abstract class AppDatabase :
companion object {
const val name = "db-mail"
const val version = 36
const val version = 37
internal val migrations = listOf(
AppDatabaseMigrations.MIGRATION_1_2,
@@ -210,7 +210,8 @@ abstract class AppDatabase :
AppDatabaseMigrations.MIGRATION_32_33,
AppDatabaseMigrations.MIGRATION_33_34,
AppDatabaseMigrations.MIGRATION_34_35,
AppDatabaseMigrations.MIGRATION_35_36
AppDatabaseMigrations.MIGRATION_35_36,
AppDatabaseMigrations.MIGRATION_36_37
)
fun buildDatabase(context: Context): AppDatabase = databaseBuilder<AppDatabase>(context, name)
@@ -242,4 +242,9 @@ object AppDatabaseMigrations {
override fun migrate(db: SupportSQLiteDatabase) {
}
}
val MIGRATION_36_37 = object : Migration(36, 37) {
override fun migrate(db: SupportSQLiteDatabase) {
}
}
}
@@ -34,6 +34,7 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -54,7 +55,6 @@ import ch.protonmail.android.mailmessage.domain.model.MessageId
import ch.protonmail.android.mailsidebar.presentation.Sidebar
import ch.protonmail.android.navigation.model.Destination.Dialog
import ch.protonmail.android.navigation.model.Destination.Screen
import ch.protonmail.android.navigation.model.HomeState
import ch.protonmail.android.navigation.route.addAlternativeRoutingSetting
import ch.protonmail.android.navigation.route.addAppSettings
import ch.protonmail.android.navigation.route.addAutoLockPinScreen
@@ -96,7 +96,6 @@ import io.sentry.compose.withSentryObservableEffect
import kotlinx.coroutines.launch
import me.proton.core.compose.component.ProtonSnackbarHostState
import me.proton.core.compose.component.ProtonSnackbarType
import me.proton.core.compose.flow.rememberAsState
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.network.domain.NetworkStatus
@@ -117,7 +116,7 @@ fun Home(
val snackbarHostNormState = remember { ProtonSnackbarHostState(defaultType = ProtonSnackbarType.NORM) }
val snackbarHostErrorState = remember { ProtonSnackbarHostState(defaultType = ProtonSnackbarType.ERROR) }
val scope = rememberCoroutineScope()
val state = rememberAsState(flow = viewModel.state, initial = HomeState.Initial)
val state by viewModel.state.collectAsStateWithLifecycle()
val offlineSnackbarMessage = stringResource(id = R.string.you_are_offline)
fun showOfflineSnackbar() = scope.launch {
@@ -127,13 +126,13 @@ fun Home(
)
}
ConsumableLaunchedEffect(state.value.networkStatusEffect) {
ConsumableLaunchedEffect(state.networkStatusEffect) {
if (it == NetworkStatus.Disconnected) {
showOfflineSnackbar()
}
}
ConsumableLaunchedEffect(state.value.navigateToEffect) {
ConsumableLaunchedEffect(state.navigateToEffect) {
viewModel.navigateTo(navController, it)
}
@@ -245,7 +244,7 @@ fun Home(
undoActionEffect.value = Effect.of(actionResult)
}
ConsumableLaunchedEffect(state.value.messageSendingStatusEffect) { sendingStatus ->
ConsumableLaunchedEffect(state.messageSendingStatusEffect) { sendingStatus ->
when (sendingStatus) {
is MessageSendingStatus.MessageSent -> showSuccessSendingMessageSnackbar()
is MessageSendingStatus.SendMessageError -> showErrorSendingMessageSnackbar()
@@ -35,7 +35,7 @@ fun Launcher(activityActions: MainActivity.Actions, viewModel: LauncherViewModel
when (state) {
LauncherState.AccountNeeded -> viewModel.submit(LauncherViewModel.Action.AddAccount)
LauncherState.PrimaryExist -> Home(
LauncherState.PrimaryExist -> LauncherRouter(
activityActions = activityActions,
launcherActions = Launcher.Actions(
onPasswordManagement = { viewModel.submit(LauncherViewModel.Action.OpenPasswordManagement) },
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.navigation
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ch.protonmail.android.MainActivity
import ch.protonmail.android.navigation.model.OnboardingEligibilityState
import ch.protonmail.android.navigation.onboarding.Onboarding
import me.proton.core.compose.component.ProtonCenteredProgress
@Composable
internal fun LauncherRouter(
activityActions: MainActivity.Actions,
launcherActions: Launcher.Actions,
viewModel: LauncherRouterViewModel = hiltViewModel()
) {
val onboardingState by viewModel.onboardingEligibilityState.collectAsStateWithLifecycle()
when (onboardingState) {
OnboardingEligibilityState.Loading -> ProtonCenteredProgress(Modifier.fillMaxSize())
OnboardingEligibilityState.NotRequired -> Home(activityActions, launcherActions)
OnboardingEligibilityState.Required -> Onboarding()
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.navigation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.Either
import ch.protonmail.android.mailcommon.domain.model.PreferencesError
import ch.protonmail.android.mailonboarding.domain.model.OnboardingPreference
import ch.protonmail.android.mailonboarding.domain.usecase.ObserveOnboarding
import ch.protonmail.android.navigation.model.OnboardingEligibilityState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
internal class LauncherRouterViewModel @Inject constructor(
observeOnboarding: ObserveOnboarding
) : ViewModel() {
val onboardingEligibilityState = observeOnboarding()
.mapLatest { it.toState() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = OnboardingEligibilityState.Loading
)
private fun Either<PreferencesError, OnboardingPreference>.toState(): OnboardingEligibilityState {
val preference = this.getOrNull() ?: return OnboardingEligibilityState.Required
return if (preference.display) OnboardingEligibilityState.Required else OnboardingEligibilityState.NotRequired
}
}
@@ -193,6 +193,11 @@ sealed class Destination(val route: String) {
object ManageMembers : Destination("contacts/group/manageMembers")
object ContactSearch : Destination("contacts/search")
object Onboarding {
data object MainScreen : Destination("onboarding/main")
data object Upselling : Destination("onboarding/upselling")
}
}
object Dialog {
@@ -16,14 +16,10 @@
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.mailonboarding.presentation.model
package ch.protonmail.android.navigation.model
sealed interface OnboardingOperation {
sealed interface Action : OnboardingOperation {
data object CloseOnboarding : Action
}
sealed interface Event : OnboardingOperation {
data object ShowOnboarding : Event
}
internal sealed interface OnboardingEligibilityState {
data object Required : OnboardingEligibilityState
data object NotRequired : OnboardingEligibilityState
data object Loading : OnboardingEligibilityState
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.navigation.onboarding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import ch.protonmail.android.navigation.model.Destination
import ch.protonmail.android.navigation.route.addOnboarding
import ch.protonmail.android.navigation.route.addOnboardingUpselling
import io.sentry.compose.withSentryObservableEffect
@Composable
fun Onboarding() {
val navController = rememberNavController().withSentryObservableEffect()
val onboardingStepViewModel = hiltViewModel<OnboardingStepViewModel>()
val exitAction = remember {
{
onboardingStepViewModel.submit(OnboardingStepAction.MarkOnboardingComplete)
}
}
NavHost(
modifier = Modifier.fillMaxSize(),
navController = navController,
startDestination = Destination.Screen.Onboarding.MainScreen.route
) {
addOnboarding(navController, exitAction)
addOnboardingUpselling(exitAction)
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.navigation.onboarding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import ch.protonmail.android.mailonboarding.domain.usecase.SaveOnboarding
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class OnboardingStepViewModel @Inject constructor(
private val saveOnboarding: SaveOnboarding
) : ViewModel() {
fun submit(action: OnboardingStepAction) {
viewModelScope.launch {
when (action) {
OnboardingStepAction.MarkOnboardingComplete -> saveOnboarding(display = false)
}
}
}
}
internal sealed interface OnboardingStepAction {
data object MarkOnboardingComplete : OnboardingStepAction
}
@@ -592,4 +592,3 @@ internal fun NavGraphBuilder.addContactSearch(navController: NavHostController)
)
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.navigation.route
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import ch.protonmail.android.mailonboarding.presentation.OnboardingScreen
import ch.protonmail.android.mailupselling.presentation.ui.onboarding.OnboardingUpsellScreen
import ch.protonmail.android.navigation.model.Destination
fun NavGraphBuilder.addOnboarding(navController: NavHostController, exitAction: () -> Unit) {
composable(route = Destination.Screen.Onboarding.MainScreen.route) {
OnboardingScreen(
exitAction = exitAction,
onUpsellingNavigation = {
navController.navigate(Destination.Screen.Onboarding.Upselling.route) {
popUpTo(Destination.Screen.Onboarding.MainScreen.route) { inclusive = true }
}
}
)
}
}
fun NavGraphBuilder.addOnboardingUpselling(exitAction: () -> Unit) {
composable(route = Destination.Screen.Onboarding.Upselling.route) {
OnboardingUpsellScreen(
modifier = Modifier,
exitScreen = exitAction
)
}
}
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Error al enviar el mensaje</string>
<string name="mailbox_message_sending_error_action">Ir a Borradores</string>
<string name="mailbox_attachment_uploading_error">Error al cargar el archivo adjunto</string>
<string name="notification_switched_account">Cambiada a la cuenta: %s</string>
<string name="notification_switched_account">Se cambió a la cuenta %s</string>
<string name="label_saved">Etiqueta guardada</string>
<string name="label_deleted">Etiqueta eliminada</string>
<string name="label_list_loading_error">Error al cargar la etiqueta. Intente de nuevo más tarde.</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Памылка адпраўкі паведамлення</string>
<string name="mailbox_message_sending_error_action">Перайсці ў Чарнавікі</string>
<string name="mailbox_attachment_uploading_error">Памылка запампоўвання далучэння</string>
<string name="notification_switched_account">Зменена на ўліковы запіс: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Метка захавана</string>
<string name="label_deleted">Метка выдалена</string>
<string name="label_list_loading_error">Памылка загрузкі меткі. Паспрабуйце пазней</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Error enviant el missatge</string>
<string name="mailbox_message_sending_error_action">Aneu a Esborranys</string>
<string name="mailbox_attachment_uploading_error">S\'ha produït un error en carregar l\'adjunt.</string>
<string name="notification_switched_account">Canviat al compte: %s</string>
<string name="notification_switched_account">S\'ha canviat al compte %s</string>
<string name="label_saved">S\'ha desat l\'etiqueta.</string>
<string name="label_deleted">S\'ha eliminat l\'etiqueta.</string>
<string name="label_list_loading_error">S\'ha produït un error en carregar l\'etiqueta. Proveu de nou més tard.</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Chyba při odesílání zprávy</string>
<string name="mailbox_message_sending_error_action">Přejít do Konceptů</string>
<string name="mailbox_attachment_uploading_error">Chyba při nahrávání přílohy</string>
<string name="notification_switched_account">Přepnuto na účet: \'%s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Štítek uložen</string>
<string name="label_deleted">Štítek smazán</string>
<string name="label_list_loading_error">Chyba při načítání štítku, zkuste to prosím později</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Besked om fejlafsendelse</string>
<string name="mailbox_message_sending_error_action">Gå til kladder</string>
<string name="mailbox_attachment_uploading_error">Fejl ved upload af vedhæftning</string>
<string name="notification_switched_account">Skiftede til konto: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Etiket gemt</string>
<string name="label_deleted">Etiket slettet</string>
<string name="label_list_loading_error">Indlæsningsfejl af etikette. Prøv igen senere.</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Fehler beim Senden der Nachricht</string>
<string name="mailbox_message_sending_error_action">Zu den Entwürfen</string>
<string name="mailbox_attachment_uploading_error">Fehler beim Hochladen des Anhangs.</string>
<string name="notification_switched_account">Zu Konto „%s“ gewechselt</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Kategorie gespeichert</string>
<string name="label_deleted">Kategorie gelöscht</string>
<string name="label_list_loading_error">Fehler beim Laden der Kategorie. Bitte versuche es später erneut.</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Σφάλμα κατά την αποστολή του μηνύματος</string>
<string name="mailbox_message_sending_error_action">Μετάβαση στα Πρόχειρα</string>
<string name="mailbox_attachment_uploading_error">Σφάλμα κατά τη μεταφόρτωση συνημμένου</string>
<string name="notification_switched_account">Έγινε αλλαγή σε λογαριασμό: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Η ετικέτα αποθηκεύτηκε</string>
<string name="label_deleted">Η ετικέτα διαγράφηκε</string>
<string name="label_list_loading_error">Σφάλμα φόρτωσης ετικέτας, παρακαλούμε δοκιμάστε ξανά αργότερα</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Error al enviar el mensaje</string>
<string name="mailbox_message_sending_error_action">Ir a la carpeta de borradores</string>
<string name="mailbox_attachment_uploading_error">Error al cargar el archivo adjunto</string>
<string name="notification_switched_account">Se ha cambiado a la cuenta: %s</string>
<string name="notification_switched_account">Se ha cambiado a la cuenta %s</string>
<string name="label_saved">Se ha guardado la etiqueta.</string>
<string name="label_deleted">Se ha eliminado la etiqueta.</string>
<string name="label_list_loading_error">Error al cargar la etiqueta. Intenta de nuevo más tarde.</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Pogreška pri slanju poruke</string>
<string name="mailbox_message_sending_error_action">Idi u skice</string>
<string name="mailbox_attachment_uploading_error">Error uploading attachment</string>
<string name="notification_switched_account">Prebačeno na račun %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Oznaka spremljena</string>
<string name="label_deleted">Oznaka izbrisana</string>
<string name="label_list_loading_error">Pogreška pri učitavanju oznake, pokušajte ponovno kasnije</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Hiba az üzenet küldése közben</string>
<string name="mailbox_message_sending_error_action">Ugrás a piszkozatokhoz</string>
<string name="mailbox_attachment_uploading_error">Hiba mellékletek feltöltésénél</string>
<string name="notification_switched_account">Átlépve a fiókba: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Címke mentve</string>
<string name="label_deleted">Címke törölve</string>
<string name="label_list_loading_error">Hiba a címke betöltése során, próbálja újra később</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Masalah dalam mengirim pesan</string>
<string name="mailbox_message_sending_error_action">Ke Draf</string>
<string name="mailbox_attachment_uploading_error">Kesalahan mengunggah lampiran</string>
<string name="notification_switched_account">Anda telah beralih ke akun: \'%s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Label disimpan</string>
<string name="label_deleted">Label dihapus</string>
<string name="label_list_loading_error">Terdapat kesalahan dalam memuat label, silakan coba lagi nanti</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Invio del messaggio non riuscito</string>
<string name="mailbox_message_sending_error_action">Vai alle bozze</string>
<string name="mailbox_attachment_uploading_error">Caricamento dell\'allegato non riuscito</string>
<string name="notification_switched_account">Account attuale: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Etichetta salvata</string>
<string name="label_deleted">Etichetta eliminata</string>
<string name="label_list_loading_error">Caricamento dell\'etichetta non riuscito</string>
+2 -2
View File
@@ -36,10 +36,10 @@
<string name="mailbox_message_sending_error">メッセージ送信エラー</string>
<string name="mailbox_message_sending_error_action">下書きを開く</string>
<string name="mailbox_attachment_uploading_error">添付ファイルのアップロードエラー</string>
<string name="notification_switched_account">アカウントを %s に切り替えました</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">ラベルが保存されました</string>
<string name="label_deleted">ラベルが削除されました</string>
<string name="label_list_loading_error">Error loading label, please try again later</string>
<string name="label_list_loading_error">ラベルの読み込み中にエラーが発生しました。後でもう一度お試しください。</string>
<string name="app_locked">アプリはロックされています。</string>
<string name="use_pin_instead">代わりにPINコードを使用する</string>
</resources>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">შეტყობინების გაგზავნის შეცდომა</string>
<string name="mailbox_message_sending_error_action">მონახაზებზე გადასვლა</string>
<string name="mailbox_attachment_uploading_error">მიმაგრებული ფაილის ატვირთვის შეცდომა</string>
<string name="notification_switched_account">გადაერთეთ ანგარიშზე: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">ჭდე შენახულია</string>
<string name="label_deleted">ჭდე წაშლილია</string>
<string name="label_list_loading_error">ჭდის ჩატვირთვის შეცდომა. მოგვიანებით სცადეთ</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Yalla-d tuccḍa deg tuzna n yizen</string>
<string name="mailbox_message_sending_error_action">Ddu ɣer Yirewwayen</string>
<string name="mailbox_attachment_uploading_error">Error uploading attachment</string>
<string name="notification_switched_account">Tnekzeḍ ɣer umiḍan: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Label saved</string>
<string name="label_deleted">Tabzimt tettwakkes</string>
<string name="label_list_loading_error">Error loading label, please try again later</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">메시지 전송 오류</string>
<string name="mailbox_message_sending_error_action">임시 보관함으로 이동하기</string>
<string name="mailbox_attachment_uploading_error">첨부 파일 업로드 중 오류</string>
<string name="notification_switched_account">%s 계정으로 전환됨</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">라벨을 저장했습니다.</string>
<string name="label_deleted">라벨을 삭제했습니다.</string>
<string name="label_list_loading_error">라벨 로드 중 오류 발생, 다시 시도하십시오.</string>
+2 -2
View File
@@ -36,10 +36,10 @@
<string name="mailbox_message_sending_error">Feil ved sending av melding</string>
<string name="mailbox_message_sending_error_action">Gå til utkast</string>
<string name="mailbox_attachment_uploading_error">Feil ved opplasting av vedlegg</string>
<string name="notification_switched_account">Byttet til konto: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Etikett lagret</string>
<string name="label_deleted">Etikett slettet</string>
<string name="label_list_loading_error">Feil ved lasting av etikett, vennligst prøv igjen senere.</string>
<string name="label_list_loading_error">Feil ved lasting av etikett. Prøv igjen senere.</string>
<string name="app_locked">App låst</string>
<string name="use_pin_instead">Bruk PIN-kode istedenfor</string>
</resources>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Fout bij verzenden bericht</string>
<string name="mailbox_message_sending_error_action">Ga naar Concepten</string>
<string name="mailbox_attachment_uploading_error">Fout bij het uploaden van bijlage</string>
<string name="notification_switched_account">Overgeschakeld naar account: %s</string>
<string name="notification_switched_account">Overgeschakeld naar account %s</string>
<string name="label_saved">Label opgeslagen</string>
<string name="label_deleted">Label verwijderd</string>
<string name="label_list_loading_error">Fout tijdens laden label, probeer het later opnieuw</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Wystąpił błąd podczas wysyłania wiadomości</string>
<string name="mailbox_message_sending_error_action">Przejdź do Szkiców</string>
<string name="mailbox_attachment_uploading_error">Wystąpił błąd podczas przesyłania załącznika</string>
<string name="notification_switched_account">Przełączono na konto %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Etykieta została zapisana</string>
<string name="label_deleted">Etykieta została usunięta</string>
<string name="label_list_loading_error">Wystąpił błąd podczas ładowania etykiety. Spróbuj ponownie później</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Erro no envio da mensagem</string>
<string name="mailbox_message_sending_error_action">Ir para Rascunhos</string>
<string name="mailbox_attachment_uploading_error">Erro ao enviar anexo</string>
<string name="notification_switched_account">Mudou para a conta %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Marcador salvo</string>
<string name="label_deleted">Marcador excluído</string>
<string name="label_list_loading_error">Erro ao carregar o marcador, tente novamente mais tarde</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Erro no envio da mensagem</string>
<string name="mailbox_message_sending_error_action">Ir para Rascunhos</string>
<string name="mailbox_attachment_uploading_error">Erro a enviar o anexo</string>
<string name="notification_switched_account">Mudou para a conta: \'%s\'.</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Etiqueta guardada</string>
<string name="label_deleted">Etiqueta eliminada</string>
<string name="label_list_loading_error">Erro ao carregar a etiqueta, por favor, tente outra vez mais tarde</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Eroare trimitere mesaj</string>
<string name="mailbox_message_sending_error_action">Accesare Ciorne</string>
<string name="mailbox_attachment_uploading_error">Eroare la încărcare atașament.</string>
<string name="notification_switched_account">S-a comutat la contul: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Etichetă a fost salvată.</string>
<string name="label_deleted">Etichetă a fost ștearsă.</string>
<string name="label_list_loading_error">Eroare la încărcarea etichetei. Reîncercați mai târziu.</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Ошибка при отправке сообщения</string>
<string name="mailbox_message_sending_error_action">Перейти в Черновики</string>
<string name="mailbox_attachment_uploading_error">Ошибка загрузки вложения</string>
<string name="notification_switched_account">Переключено на аккаунт: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Ярлык сохранён</string>
<string name="label_deleted">Ярлык удалён</string>
<string name="label_list_loading_error">Ошибка загрузки ярлыка, повторите попытку позже</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Chyba pri odosielaní správy</string>
<string name="mailbox_message_sending_error_action">Prejsť do zložky Koncepty</string>
<string name="mailbox_attachment_uploading_error">Chyba pri nahrávaní prílohy</string>
<string name="notification_switched_account">Prepnuté na účet: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Štítok uložený</string>
<string name="label_deleted">Štítok odstránený</string>
<string name="label_list_loading_error">Chyba pri načítaní štítku, skúste to znova neskôr, prosím</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Napaka pri pošiljanju sporočila</string>
<string name="mailbox_message_sending_error_action">Pojdi v mapo Osnutki</string>
<string name="mailbox_attachment_uploading_error">Napaka pri nalaganju priponke</string>
<string name="notification_switched_account">Preklopili ste na račun: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Oznaka shranjena</string>
<string name="label_deleted">Oznaka izbrisana</string>
<string name="label_list_loading_error">Napaka pri nalaganju oznake, poskusite znova pozneje</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Det gick inte att skicka meddelandet</string>
<string name="mailbox_message_sending_error_action">Gå till utkast</string>
<string name="mailbox_attachment_uploading_error">Kunde inte ladda upp bilaga</string>
<string name="notification_switched_account">Bytte till konto: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Etikett sparad</string>
<string name="label_deleted">Etikett borttagen</string>
<string name="label_list_loading_error">Gick inte ladda etikett, försök igen senare</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">İleti gönderilirken sorun çıktı</string>
<string name="mailbox_message_sending_error_action">Taslaklar kutusuna git</string>
<string name="mailbox_attachment_uploading_error">Ek dosya yüklenirken sorun çıktı</string>
<string name="notification_switched_account">Geçilen hesap: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Etiket kaydedldi</string>
<string name="label_deleted">Etiket silindi</string>
<string name="label_list_loading_error">Etiket yüklenirken sorun çıktı. Lütfen bir süre sonra yeniden deneyin</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">Помилка надсилання повідомлення</string>
<string name="mailbox_message_sending_error_action">Перейти до Чернеток</string>
<string name="mailbox_attachment_uploading_error">Помилка вивантаження вкладення</string>
<string name="notification_switched_account">Змінено на обліковий запис: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Мітку збережено</string>
<string name="label_deleted">Мітку видалено</string>
<string name="label_list_loading_error">Помилка завантаження мітки, спробуйте знову пізніше</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">发送信息时出错</string>
<string name="mailbox_message_sending_error_action">转至草稿箱</string>
<string name="mailbox_attachment_uploading_error">上传附件时出错</string>
<string name="notification_switched_account">已转至 %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">标签已保存</string>
<string name="label_deleted">标签已删除</string>
<string name="label_list_loading_error">加载标签出错。请稍后再试。</string>
+1 -1
View File
@@ -36,7 +36,7 @@
<string name="mailbox_message_sending_error">傳送郵件時發生錯誤</string>
<string name="mailbox_message_sending_error_action">前往草稿資料夾</string>
<string name="mailbox_attachment_uploading_error">上載附件出現錯誤</string>
<string name="notification_switched_account">已切換至帳號「%s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">已儲存標籤</string>
<string name="label_deleted">已刪除標籤</string>
<string name="label_list_loading_error">載入標籤時發生錯誤,請稍後再試。</string>
+1
View File
@@ -6,4 +6,5 @@
<bool name="core_feature_notifications_enabled">true</bool>
<!-- Mail already handle the Notification Permission (after successful login). -->
<bool name="core_feature_notifications_permission_request_enabled">false</bool>
<integer name="core_feature_auth_user_check_max_free_user_count">2</integer>
</resources>
+1 -2
View File
@@ -37,11 +37,10 @@
<string name="mailbox_message_sending_error">Error sending message</string>
<string name="mailbox_message_sending_error_action">Go to Drafts</string>
<string name="mailbox_attachment_uploading_error">Error uploading attachment</string>
<string name="notification_switched_account">Switched to account: %s</string>
<string name="notification_switched_account">Switched to account %s</string>
<string name="label_saved">Label saved</string>
<string name="label_deleted">Label deleted</string>
<string name="label_list_loading_error">Error loading label, please try again later</string>
<string name="app_locked">App locked</string>
<string name="use_pin_instead">Use PIN instead</string>
</resources>
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.navigation
import app.cash.turbine.test
import arrow.core.left
import arrow.core.right
import ch.protonmail.android.mailcommon.domain.model.PreferencesError
import ch.protonmail.android.mailonboarding.domain.model.OnboardingPreference
import ch.protonmail.android.mailonboarding.domain.usecase.ObserveOnboarding
import ch.protonmail.android.navigation.model.OnboardingEligibilityState
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
internal class LauncherRouterViewModelTest {
private val observeOnboarding = mockk<ObserveOnboarding>()
private val viewModel: LauncherRouterViewModel
get() = LauncherRouterViewModel(observeOnboarding)
@BeforeTest
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@AfterTest
fun teardown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `should emit loading on observation start`() = runTest {
// Given
every { observeOnboarding() } returns flowOf()
// When + Then
viewModel.onboardingEligibilityState.test {
assertEquals(OnboardingEligibilityState.Loading, awaitItem())
}
}
@Test
fun `should emit an onboarding required state when observe onboarding returns true`() = runTest {
// Given
every { observeOnboarding() } returns flowOf(OnboardingPreference(true).right())
// When + Then
viewModel.onboardingEligibilityState.test {
assertEquals(OnboardingEligibilityState.Required, awaitItem())
}
}
@Test
fun `should emit a non onboarding required state when observe onboarding returns false`() = runTest {
// Given
every { observeOnboarding() } returns flowOf(OnboardingPreference(false).right())
// When + Then
viewModel.onboardingEligibilityState.test {
assertEquals(OnboardingEligibilityState.NotRequired, awaitItem())
}
}
@Test
fun `should emit an onboarding required state when observe onboarding errors`() = runTest {
// Given
every { observeOnboarding() } returns flowOf(PreferencesError.left())
// When + Then
viewModel.onboardingEligibilityState.test {
assertEquals(OnboardingEligibilityState.Required, awaitItem())
}
}
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.navigation
import ch.protonmail.android.mailonboarding.domain.usecase.SaveOnboarding
import ch.protonmail.android.navigation.onboarding.OnboardingStepAction
import ch.protonmail.android.navigation.onboarding.OnboardingStepViewModel
import io.mockk.coVerify
import io.mockk.confirmVerified
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
internal class OnboardingStepViewModelTest {
private val saveOnboarding = mockk<SaveOnboarding>(relaxed = true)
private val viewModel = OnboardingStepViewModel(saveOnboarding)
@BeforeTest
fun setup() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@AfterTest
fun teardown() {
Dispatchers.resetMain()
}
@Test
fun `should call save onboarding when marking onboarding as completed`() = runTest {
// When
viewModel.submit(OnboardingStepAction.MarkOnboardingComplete)
// Then
coVerify(exactly = 1) { saveOnboarding(false) }
confirmVerified(saveOnboarding)
}
}
@@ -29,10 +29,7 @@ import ch.protonmail.android.uitest.helpers.core.navigation.Destination
import ch.protonmail.android.uitest.helpers.core.navigation.navigator
import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher
import ch.protonmail.android.uitest.robot.composer.composerRobot
import ch.protonmail.android.uitest.robot.composer.section.attachmentsBottomSheet
import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection
import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
import ch.protonmail.android.uitest.robot.composer.section.verify
import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailItemEntry
import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection
import ch.protonmail.android.uitest.robot.detail.section.verify
@@ -45,14 +42,13 @@ import dagger.hilt.android.testing.UninstallModules
import io.mockk.mockk
import me.proton.core.auth.domain.usecase.ValidateServerProof
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
@RegressionTest
@SdkSuppress(minSdkVersion = 30, maxSdkVersion = 32)
@HiltAndroidTest
@UninstallModules(ServerProofModule::class)
internal class ComposerAttachmentsBottomSheetTests : MockedNetworkTest(), ComposerAttachmentsTests {
internal class ComposerAttachmentsButtonTests : MockedNetworkTest(), ComposerAttachmentsTests {
@JvmField
@BindValue
@@ -79,14 +75,9 @@ internal class ComposerAttachmentsBottomSheetTests : MockedNetworkTest(), Compos
@Test
@TestId("226087", "226088")
fun testMainAttachmentsBottomSheetInteractions() {
fun testMainAttachmentsButtonInteractions() {
composerRobot {
topAppBarSection { tapAttachmentsButton() }
attachmentsBottomSheet {
verify { hasImportOption() }
tapImportItem()
}
}
deviceRobot {
@@ -94,54 +85,12 @@ internal class ComposerAttachmentsBottomSheetTests : MockedNetworkTest(), Compos
}
}
@Test
@Ignore("To be enabled again when MAILANDR-1101 is addressed.")
@TestId("226089")
fun testAttachmentsBottomSheetDismissalWithBackButton() {
composerRobot {
topAppBarSection { tapAttachmentsButton() }
attachmentsBottomSheet {
verify { isShown() }
}
}
deviceRobot { pressBack() }
composerRobot {
attachmentsBottomSheet {
verify { isDismissed() }
}
}
}
@Test
@TestId("226090")
fun testAttachmentsBottomSheetDismissalWithExternalTap() {
composerRobot {
topAppBarSection { tapAttachmentsButton() }
attachmentsBottomSheet {
verify { isShown() }
}
toRecipientSection { expandCcAndBccFields() }
}
composerRobot {
attachmentsBottomSheet {
verify { isDismissed() }
}
}
}
@Test
@SdkSuppress(minSdkVersion = 29)
@TestId("226090")
fun testAttachmentChipEntryUponPicking() {
composerRobot {
topAppBarSection { tapAttachmentsButton() }
attachmentsBottomSheet { tapImportItem() }
attachmentsSection { verify { hasAttachments(defaultExpectedEntry) } }
}
}
@@ -157,11 +106,9 @@ internal class ComposerAttachmentsBottomSheetTests : MockedNetworkTest(), Compos
composerRobot {
topAppBarSection { tapAttachmentsButton() }
attachmentsBottomSheet { tapImportItem() }
attachmentsSection { verify { hasAttachments(defaultExpectedEntry) } }
topAppBarSection { tapAttachmentsButton() }
attachmentsBottomSheet { tapImportItem() }
attachmentsSection { verify { hasAttachments(*expectedEntries) } }
}
}
@@ -38,7 +38,6 @@ import ch.protonmail.android.uitest.robot.common.section.verify
import ch.protonmail.android.uitest.robot.composer.ComposerRobot
import ch.protonmail.android.uitest.robot.composer.composerRobot
import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar
import ch.protonmail.android.uitest.robot.composer.section.attachmentsBottomSheet
import ch.protonmail.android.uitest.robot.composer.section.messageBodySection
import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection
import ch.protonmail.android.uitest.robot.composer.section.subjectSection
@@ -167,6 +166,5 @@ internal class ComposerSendMessageWithAttachmentsTests :
messageBodySection { typeMessageBody(body) }
topAppBarSection { tapAttachmentsButton() }
attachmentsBottomSheet { tapImportItem() }
}
}
@@ -25,12 +25,9 @@ import ch.protonmail.android.uitest.MockedNetworkTest
import ch.protonmail.android.uitest.helpers.core.TestId
import ch.protonmail.android.uitest.helpers.core.navigation.Destination
import ch.protonmail.android.uitest.helpers.core.navigation.navigator
import ch.protonmail.android.uitest.robot.common.section.snackbarSection
import ch.protonmail.android.uitest.robot.common.section.verify
import ch.protonmail.android.uitest.robot.composer.composerRobot
import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry
import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipValidationState
import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar
import ch.protonmail.android.uitest.robot.composer.section.recipients.bccRecipientSection
import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection
import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection
@@ -113,10 +110,6 @@ internal class ComposerRecipientsDuplicatedChipsTests : MockedNetworkTest(), Com
recipientChipIsNotDisplayed(expectedNotExistsChip)
}
}
snackbarSection {
verify { isDisplaying(ComposerSnackbar.DuplicateEmailAddress(expectedFocusedRecipientChip.text)) }
}
}
}
}
@@ -26,13 +26,10 @@ import ch.protonmail.android.uitest.MockedNetworkTest
import ch.protonmail.android.uitest.helpers.core.TestId
import ch.protonmail.android.uitest.helpers.core.navigation.Destination
import ch.protonmail.android.uitest.helpers.core.navigation.navigator
import ch.protonmail.android.uitest.robot.common.section.snackbarSection
import ch.protonmail.android.uitest.robot.common.section.verify
import ch.protonmail.android.uitest.robot.composer.composerRobot
import ch.protonmail.android.uitest.robot.composer.model.chips.ChipsCreationTrigger
import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry
import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipValidationState
import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar
import ch.protonmail.android.uitest.robot.composer.section.recipients.ComposerRecipientsSection
import ch.protonmail.android.uitest.robot.composer.section.recipients.bccRecipientSection
import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection
@@ -75,8 +72,6 @@ internal class ComposerRecipientsInvalidChipsTests : MockedNetworkTest(), Compos
toRecipientSection {
createAndVerifyInvalidChip()
}
snackbarSection { verify { isDisplaying(ComposerSnackbar.InvalidEmailAddress) } }
}
}
@@ -91,8 +86,6 @@ internal class ComposerRecipientsInvalidChipsTests : MockedNetworkTest(), Compos
ccRecipientSection {
createAndVerifyInvalidChip()
}
snackbarSection { verify { isDisplaying(ComposerSnackbar.InvalidEmailAddress) } }
}
}
@@ -107,8 +100,6 @@ internal class ComposerRecipientsInvalidChipsTests : MockedNetworkTest(), Compos
bccRecipientSection {
createAndVerifyInvalidChip()
}
snackbarSection { verify { isDisplaying(ComposerSnackbar.InvalidEmailAddress) } }
}
}
@@ -128,8 +119,6 @@ internal class ComposerRecipientsInvalidChipsTests : MockedNetworkTest(), Compos
toRecipientSection {
verify { hasRecipientChips(expectedRecipientChip) }
}
snackbarSection { verify { isDisplaying(ComposerSnackbar.InvalidEmailAddress) } }
}
}
@@ -140,8 +129,6 @@ internal class ComposerRecipientsInvalidChipsTests : MockedNetworkTest(), Compos
toRecipientSection {
createAndVerifyInvalidChip(trigger = ChipsCreationTrigger.Spacebar)
}
snackbarSection { verify { isDisplaying(ComposerSnackbar.InvalidEmailAddress) } }
}
}
@@ -154,7 +141,6 @@ internal class ComposerRecipientsInvalidChipsTests : MockedNetworkTest(), Compos
typeRecipient(it.text, autoConfirm = true)
verify { hasRecipientChips(it) }
snackbarSection { verify { isDisplaying(ComposerSnackbar.InvalidEmailAddress) } }
}
}
}
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments
import ch.protonmail.android.di.ServerProofModule
import ch.protonmail.android.networkmocks.mockwebserver.combineWith
import ch.protonmail.android.networkmocks.mockwebserver.requests.MimeType
import ch.protonmail.android.networkmocks.mockwebserver.requests.get
import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams
import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards
import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce
import ch.protonmail.android.networkmocks.mockwebserver.requests.withMimeType
import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
import ch.protonmail.android.test.annotations.suite.RegressionTest
import ch.protonmail.android.test.annotations.suite.SmokeTest
import ch.protonmail.android.uitest.MockedNetworkTest
import ch.protonmail.android.uitest.helpers.core.TestId
import ch.protonmail.android.uitest.helpers.core.navigation.Destination
import ch.protonmail.android.uitest.helpers.core.navigation.navigator
import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes
import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher
import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection
import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
import ch.protonmail.android.uitest.robot.helpers.deviceRobot
import ch.protonmail.android.uitest.robot.helpers.section.intents
import ch.protonmail.android.uitest.robot.helpers.section.verify
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import io.mockk.mockk
import me.proton.core.auth.domain.usecase.ValidateServerProof
import kotlin.test.Test
@RegressionTest
@UninstallModules(ServerProofModule::class)
@HiltAndroidTest
internal class AttachmentMessageModeTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) {
@JvmField
@BindValue
val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
@Test
@SmokeTest
@TestId("417285")
fun testAttachmentOpeningWithHeaderMaps() {
mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
addMockRequests(
get("/mail/v4/settings")
respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json"
withStatusCode 200,
get("/mail/v4/messages")
respondWith "/mail/v4/messages/messages_417285.json"
withStatusCode 200 ignoreQueryParams true,
get("/mail/v4/messages/*")
respondWith "/mail/v4/messages/message-id/message-id_417285.json"
withStatusCode 200 matchWildcards true serveOnce true,
get("/mail/v4/attachments/*")
respondWith "/mail/v4/attachments/attachment_417285"
withStatusCode 200 matchWildcards true serveOnce true
withMimeType MimeType.OctetStream
)
}
navigator { navigateTo(Destination.MailDetail()) }
messageDetailRobot {
messageBodySection { waitUntilMessageIsShown() }
attachmentsSection { tapItem() }
deviceRobot {
intents { verify { actionViewIntentWasLaunched(times = 1) } }
}
}
}
}
@@ -39,10 +39,18 @@ internal interface OpenExistingDraftsTest {
) {
toRecipientSection {
verify { hasRecipientChips(toRecipientChip) }
expandCcAndBccFields()
}
ccRecipientSection { verify { ccRecipientChip?.let { hasRecipientChips(it) } ?: isEmptyField() } }
bccRecipientSection { verify { bccRecipientChip?.let { hasRecipientChips(it) } ?: isEmptyField() } }
if (ccRecipientChip != null && bccRecipientChip != null) {
toRecipientSection { verify { chevronNotVisible() } }
ccRecipientSection { verify { hasRecipientChips(ccRecipientChip) } }
bccRecipientSection { verify { hasRecipientChips(bccRecipientChip) } }
} else {
toRecipientSection { expandCcAndBccFields() }
ccRecipientSection { verify { isEmptyField() } }
bccRecipientSection { verify { isEmptyField() } }
}
subjectSection { verify { hasSubject(subject) } }
messageBodySection { verify { messageBody?.let { hasText(it) } ?: hasPlaceholderText() } }
}
@@ -33,10 +33,11 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.pressKey
import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags
import ch.protonmail.android.test.utils.ComposeTestRuleHolder
import ch.protonmail.android.uicomponents.chips.ChipsTestTags
import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry
import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntryModel
import ch.protonmail.android.test.utils.ComposeTestRuleHolder
import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipValidationState.Invalid
import ch.protonmail.android.uitest.util.assertions.assertEmptyText
import ch.protonmail.android.uitest.util.awaitHidden
@@ -110,7 +111,12 @@ internal sealed class ComposerRecipientsEntryModel(
model
.hasText(chip.text)
.hasEmailValidationState(chip.state)
.also { if (chip.hasDeleteIcon) model.hasDeleteIcon() else model.hasNoDeleteIcon() }
.also {
if (chip.hasDeleteIcon) model.hasDeleteIcon() else model.hasNoDeleteIcon()
}
.also {
if (chip.state is Invalid) model.hasErrorIcon() else model.hasNoErrorIcon()
}
}
}
@@ -26,8 +26,8 @@ import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import ch.protonmail.android.uicomponents.chips.ChipsTestTags
import ch.protonmail.android.test.utils.ComposeTestRuleHolder
import ch.protonmail.android.uicomponents.chips.ChipsTestTags
import ch.protonmail.android.uitest.util.assertions.CustomSemanticsPropertyKeyNames
import ch.protonmail.android.uitest.util.child
import ch.protonmail.android.uitest.util.extensions.getKeyValueByName
@@ -44,7 +44,8 @@ internal class RecipientChipEntryModel(
useUnmergedTree = true
)
private val text = parent.child { hasTestTag(ChipsTestTags.InputChipText) }
private val deleteIcon = parent.child { hasTestTag(ChipsTestTags.InputChipIcon) }
private val errorIcon = parent.child { hasTestTag(ChipsTestTags.InputChipLeadingIcon) }
private val deleteIcon = parent.child { hasTestTag(ChipsTestTags.InputChipTrailingIcon) }
// region actions
fun tapDeleteIcon() = withParentDisplayed {
@@ -69,6 +70,14 @@ internal class RecipientChipEntryModel(
deleteIcon.assertDoesNotExist()
}
fun hasErrorIcon() = withParentDisplayed {
errorIcon.assertExists()
}
fun hasNoErrorIcon() = withParentDisplayed {
errorIcon.assertDoesNotExist()
}
fun doesNotExist() {
parent.assertDoesNotExist()
}
@@ -19,6 +19,6 @@
package ch.protonmail.android.uitest.robot.composer.model.chips
internal sealed class RecipientChipValidationState(val value: Boolean) {
object Valid : RecipientChipValidationState(true)
object Invalid : RecipientChipValidationState(false)
data object Valid : RecipientChipValidationState(true)
data object Invalid : RecipientChipValidationState(false)
}
@@ -25,43 +25,35 @@ import ch.protonmail.android.uitest.util.getTestString
internal sealed class ComposerSnackbar(value: String, type: SnackbarType) : SnackbarEntry(value, type) {
object AttachmentUploadError : ComposerSnackbar(
data object AttachmentUploadError : ComposerSnackbar(
getTestString(R.string.test_mailbox_attachment_uploading_error), SnackbarType.Error
)
object DraftSaved : ComposerSnackbar(
data object DraftSaved : ComposerSnackbar(
getTestString(R.string.test_mailbox_draft_saved), SnackbarType.Success
)
object DraftOutOfSync : ComposerSnackbar(
data object DraftOutOfSync : ComposerSnackbar(
getTestString(R.string.test_composer_error_loading_draft), SnackbarType.Default
)
class DuplicateEmailAddress(recipient: String) : ComposerSnackbar(
getTestString(R.string.test_composer_error_duplicate_email, recipient), SnackbarType.Default
)
object InvalidEmailAddress : ComposerSnackbar(
getTestString(R.string.test_composer_error_invalid_email), SnackbarType.Default
)
object MessageSent : ComposerSnackbar(
data object MessageSent : ComposerSnackbar(
getTestString(R.string.test_mailbox_message_sending_success), SnackbarType.Success
)
object MessageSentError : ComposerSnackbar(
data object MessageSentError : ComposerSnackbar(
getTestString(R.string.test_mailbox_message_sending_error), SnackbarType.Error
)
object MessageQueued : ComposerSnackbar(
data object MessageQueued : ComposerSnackbar(
getTestString(R.string.test_mailbox_message_sending_offline), SnackbarType.Normal
)
object SendingMessage : ComposerSnackbar(
data object SendingMessage : ComposerSnackbar(
getTestString(R.string.test_mailbox_message_sending), SnackbarType.Normal
)
object UpgradePlanToChangeSender : ComposerSnackbar(
data object UpgradePlanToChangeSender : ComposerSnackbar(
getTestString(R.string.test_composer_change_sender_paid_feature), SnackbarType.Default
)
}
@@ -1,78 +0,0 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.uitest.robot.composer.section
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import ch.protonmail.android.mailcomposer.presentation.ui.AddAttachmentsBottomSheetTestTags
import ch.protonmail.android.test.ksp.annotations.AttachTo
import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
import ch.protonmail.android.uitest.robot.ComposeSectionRobot
import ch.protonmail.android.uitest.robot.composer.ComposerRobot
import ch.protonmail.android.uitest.util.awaitDisplayed
import ch.protonmail.android.uitest.util.awaitHidden
import ch.protonmail.android.uitest.util.child
@AttachTo(targets = [ComposerRobot::class], identifier = "attachmentsBottomSheet")
internal class ComposerAttachmentsBottomSheetSection : ComposeSectionRobot() {
private val rootItem = composeTestRule.onNodeWithTag(
AddAttachmentsBottomSheetTestTags.RootItem, useUnmergedTree = true
)
private val importRootItem = rootItem.child {
hasTestTag(AddAttachmentsBottomSheetTestTags.ImportEntry)
}
private val importIcon = importRootItem.child {
hasTestTag(AddAttachmentsBottomSheetTestTags.ImportIcon)
}
private val importText = importRootItem.child {
hasTestTag(AddAttachmentsBottomSheetTestTags.ImportText)
}
fun tapImportItem() = withVisibleItem {
importText.performClick()
}
@VerifiesOuter
inner class Verify {
fun isShown() {
rootItem.awaitDisplayed()
}
// No need for specific entry item models now.
fun hasImportOption() = withVisibleItem {
importIcon.awaitDisplayed()
importText.awaitDisplayed()
}
fun isDismissed() {
rootItem.awaitHidden()
}
}
private fun withVisibleItem(block: ComposerAttachmentsBottomSheetSection.() -> Unit) {
rootItem.awaitDisplayed()
block()
}
}
@@ -41,6 +41,10 @@ internal class ComposerRecipientsToSection : ComposerRecipientsSection(
useUnmergedTree = true
)
fun chevronNotVisible() = apply {
expandRecipientsButton.assertDoesNotExist()
}
fun expandCcAndBccFields() = apply {
expandRecipientsButton.performScrollTo().performClick()
}
@@ -23,6 +23,7 @@ import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performScrollTo
import androidx.test.espresso.Espresso
import ch.protonmail.android.mailmessage.presentation.ui.AttachmentFooterTestTags
import ch.protonmail.android.test.ksp.annotations.AttachTo
import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
@@ -74,6 +75,7 @@ internal class MessageFooterAttachmentSection : ComposeSectionRobot() {
}
private fun scrollTo() {
Espresso.closeSoftKeyboard()
rootItem.awaitDisplayed().performScrollTo()
}
@@ -18,8 +18,8 @@
package ch.protonmail.android.uitest.rule
import ch.protonmail.android.mailmailbox.data.local.OnboardingLocalDataSource
import ch.protonmail.android.mailmailbox.domain.model.OnboardingPreference
import ch.protonmail.android.mailonboarding.data.local.OnboardingLocalDataSource
import ch.protonmail.android.mailonboarding.domain.model.OnboardingPreference
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@@ -40,7 +40,6 @@ import ch.protonmail.android.mailmailbox.presentation.mailbox.model.UnreadFilter
import ch.protonmail.android.mailmailbox.presentation.mailbox.model.UpgradeStorageState
import ch.protonmail.android.mailmailbox.presentation.mailbox.previewdata.MailboxSearchStateSampleData
import ch.protonmail.android.mailmailbox.presentation.mailbox.previewdata.MailboxStateSampleData
import ch.protonmail.android.mailonboarding.presentation.model.OnboardingState
import ch.protonmail.android.test.annotations.suite.RegressionTest
import ch.protonmail.android.testdata.mailbox.MailboxItemUiModelTestData
import ch.protonmail.android.testdata.maillabel.MailLabelTestData
@@ -95,7 +94,7 @@ internal class MailboxScreenTest : HiltInstrumentedTest() {
refreshRequested = false,
swipeActions = null,
searchState = MailboxSearchStateSampleData.NotSearching,
clearState = MailboxListState.Data.ClearState.Hidden
clearState = MailboxListState.Data.ClearState.Hidden,
)
val mailboxState = MailboxStateSampleData.Loading.copy(mailboxListState = mailboxListState)
val items = listOf(MailboxItemUiModelTestData.readMailboxItemUiModel)
@@ -153,7 +152,7 @@ internal class MailboxScreenTest : HiltInstrumentedTest() {
refreshRequested = false,
swipeActions = null,
searchState = MailboxSearchStateSampleData.NotSearching,
clearState = MailboxListState.Data.ClearState.Hidden
clearState = MailboxListState.Data.ClearState.Hidden,
)
val mailboxState = MailboxStateSampleData.Loading.copy(mailboxListState = mailboxListState)
val robot = setupScreen(state = mailboxState)
@@ -173,7 +172,7 @@ internal class MailboxScreenTest : HiltInstrumentedTest() {
refreshRequested = false,
swipeActions = null,
searchState = MailboxSearchStateSampleData.NotSearching,
clearState = MailboxListState.Data.ClearState.Hidden
clearState = MailboxListState.Data.ClearState.Hidden,
)
val mailboxState = MailboxStateSampleData.Loading.copy(mailboxListState = mailboxListState)
val robot = setupScreen(state = mailboxState)
@@ -207,7 +206,7 @@ internal class MailboxScreenTest : HiltInstrumentedTest() {
refreshRequested = false,
swipeActions = null,
searchState = MailboxSearchStateSampleData.NotSearching,
clearState = MailboxListState.Data.ClearState.Hidden
clearState = MailboxListState.Data.ClearState.Hidden,
),
topAppBarState = MailboxTopAppBarState.Data.DefaultMode(
currentLabelName = systemLabel.text()
@@ -215,14 +214,13 @@ internal class MailboxScreenTest : HiltInstrumentedTest() {
upgradeStorageState = UpgradeStorageState(notificationDotVisible = false),
unreadFilterState = UnreadFilterState.Loading,
bottomAppBarState = BottomBarState.Data.Hidden(emptyList<ActionUiModel>().toImmutableList()),
onboardingState = OnboardingState.Hidden,
actionResult = Effect.empty(),
deleteDialogState = DeleteDialogState.Hidden,
deleteAllDialogState = DeleteDialogState.Hidden,
storageLimitState = StorageLimitState.HasEnoughSpace,
bottomSheetState = null,
error = Effect.empty(),
showRatingBooster = Effect.empty()
showRatingBooster = Effect.empty(),
)
}
+1 -1
View File
@@ -23,5 +23,5 @@ object Config {
const val targetSdk = 34
const val testInstrumentationRunner = "ch.protonmail.android.uitest.HiltTestRunner"
const val versionCode = 1
const val versionName = "4.0.20"
const val versionName = "4.2.0"
}
@@ -90,7 +90,7 @@ class FocusableFormScope<FocusedField> @OptIn(ExperimentalFoundationApi::class)
} else {
this
}.onFocusChanged {
if (it.isFocused) onFieldFocused(fieldType)
if (it.hasFocus || it.isFocused) onFieldFocused(fieldType)
}
}
}
@@ -26,6 +26,7 @@ object MailDimens {
val ThinBorder = 0.5.dp
val DefaultBorder = 1.dp
val AvatarBorderLine = 1.5.dp
val OnboardingUpsellBestValueBorder = 2.dp
val TinySpacing = 2.dp
@@ -120,4 +121,7 @@ object MailDimens {
}
val NarrowScreenWidth = 360.dp
val PlanSwitcherHeight = 68.dp
val OnboardingUpsellButtonHeight = 48.dp
}
@@ -18,18 +18,20 @@
package ch.protonmail.android.mailcommon.presentation.extension
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavController
import timber.log.Timber
/**
* Navigates back by popping the backstack only if the current backstack entry is
* in the `RESUMED` lifecycle state.
*
* This avoids the scenario where tapping the back button twice on the top app bar would trigger
* twice the navigation event, causing the app to eventually display a blank screen.
* Navigates back by popping the backstack only if the current backstack entry is not the starting destination.
* This avoids navigating back to a blank screen if the user taps back/exit too quickly.
*/
fun NavController.navigateBack() {
if (currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED) {
val startDestination = graph.startDestinationId
if (currentDestination?.id != startDestination) {
Timber.tag("NavController").d("Navigating back from: ${currentDestination?.route}")
popBackStack()
} else {
Timber.tag("NavController").d("Back navigation ignored, current location: ${currentDestination?.route}")
}
}
@@ -84,7 +84,7 @@
<string name="attachment_download_notification_channel_description">Notificaciones de descargas de archivos adjuntos</string>
<string name="email_notification_channel_name">Correos</string>
<string name="login_notification_channel_name">Alertas de inicio de sesión</string>
<string name="email_notification_channel_description">Notificaciones entrantes de correo electrónico</string>
<string name="email_notification_channel_description">Notificaciones de correo electrónico entrante</string>
<string name="login_notification_channel_description">Notificaciones de nuevos inicios de sesión</string>
<string name="auth_badge_official">Oficial</string>
<string name="intent_failure_no_app_found_to_handle_this_action">No se encontró una aplicación para ejecutar esta acción</string>
@@ -42,7 +42,7 @@
<string name="action_archive_description">Archivar</string>
<string name="action_spam_description">Mover a la carpeta de spam</string>
<string name="action_view_in_light_mode_description">Ver el mensaje en modo claro</string>
<string name="action_view_in_dark_mode_description">Ver mensaje en modo oscuro</string>
<string name="action_view_in_dark_mode_description">Ver el mensaje en modo oscuro</string>
<string name="action_print_description">Imprimir</string>
<string name="action_view_headers_description">Ver los encabezados</string>
<string name="action_view_html_description">Ver el código HTML</string>
@@ -102,7 +102,7 @@
<string name="color_copper">コパー</string>
<string name="color_sahara">サハラ</string>
<string name="color_soil">ソイル</string>
<string name="color_slate_blue">Slate blue</string>
<string name="color_slate_blue">スレートブルー</string>
<string name="color_cobalt">コバルト</string>
<string name="color_pacific">パシフィック</string>
<string name="color_ocean">オーシャン</string>
@@ -114,5 +114,5 @@
<string name="color_pickle">ピックル</string>
<string name="undo_button_label">取り消し</string>
<string name="undo_success_message">操作を取り消しました</string>
<string name="undo_failure_message">Action revert failed</string>
<string name="undo_failure_message">操作の取り消しに失敗しました</string>
</resources>
@@ -26,17 +26,21 @@ import ch.protonmail.android.composer.data.repository.MessageExpirationTimeRepos
import ch.protonmail.android.composer.data.repository.MessagePasswordRepositoryImpl
import ch.protonmail.android.composer.data.repository.MessageRepositoryImpl
import ch.protonmail.android.mailcomposer.domain.Transactor
import ch.protonmail.android.mailcomposer.domain.annotations.NewContactSuggestionsEnabled
import ch.protonmail.android.mailcomposer.domain.repository.AttachmentRepository
import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository
import ch.protonmail.android.mailcomposer.domain.repository.DraftRepository
import ch.protonmail.android.mailcomposer.domain.repository.MessageExpirationTimeRepository
import ch.protonmail.android.mailcomposer.domain.repository.MessagePasswordRepository
import ch.protonmail.android.mailcomposer.domain.repository.MessageRepository
import ch.protonmail.android.mailcomposer.domain.usecase.featureflags.IsNewContactsSuggestionsEnabled
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.Reusable
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@@ -72,3 +76,13 @@ abstract class MailComposerModule {
impl: MessageExpirationTimeRepositoryImpl
): MessageExpirationTimeRepository
}
@Module
@InstallIn(SingletonComponent::class)
object FeatureFlagModule {
@Provides
@NewContactSuggestionsEnabled
@Singleton
fun provideNewContactsSuggestionsEnabled(isEnabled: IsNewContactsSuggestionsEnabled) = isEnabled(null)
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.mailcomposer.domain.annotations
import javax.inject.Qualifier
/**
* Indicates to use the new contact suggestions layout.
*/
@Qualifier
annotation class NewContactSuggestionsEnabled
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.mailcomposer.domain.usecase.featureflags
import me.proton.core.domain.entity.UserId
import me.proton.core.featureflag.domain.ExperimentalProtonFeatureFlag
import me.proton.core.featureflag.domain.FeatureFlagManager
import me.proton.core.featureflag.domain.entity.FeatureId
import javax.inject.Inject
class IsNewContactsSuggestionsEnabled @Inject constructor(
private val featureFlagManager: FeatureFlagManager
) {
@OptIn(ExperimentalProtonFeatureFlag::class)
operator fun invoke(userId: UserId?) = featureFlagManager.getValue(userId, FeatureId(FeatureFlagId))
private companion object {
const val FeatureFlagId = "MailAndroidComposerNewContactsSuggestions"
}
}
@@ -30,6 +30,7 @@ data class ComposerDraftState(
val fields: ComposerFields,
val attachments: AttachmentGroupUiModel,
val premiumFeatureMessage: Effect<TextUiModel>,
val recipientValidationError: Effect<TextUiModel>,
val error: Effect<TextUiModel>,
val isSubmittable: Boolean,
val isDeviceContactsSuggestionsEnabled: Boolean,
@@ -54,7 +55,8 @@ data class ComposerDraftState(
val areContactSuggestionsExpanded: Map<ContactSuggestionsField, Boolean> = emptyMap(),
val senderChangedNotice: Effect<TextUiModel> = Effect.empty(),
val messageExpiresIn: Duration,
val confirmSendExpiringMessage: Effect<List<Participant>>
val confirmSendExpiringMessage: Effect<List<Participant>>,
val openImagePicker: Effect<Unit>
) {
companion object {
@@ -79,6 +81,7 @@ data class ComposerDraftState(
attachments = emptyList()
),
premiumFeatureMessage = Effect.empty(),
recipientValidationError = Effect.empty(),
error = Effect.empty(),
isSubmittable = isSubmittable,
senderAddresses = emptyList(),
@@ -100,7 +103,8 @@ data class ComposerDraftState(
messageExpiresIn = Duration.ZERO,
confirmSendExpiringMessage = Effect.empty(),
isDeviceContactsSuggestionsEnabled = false,
isDeviceContactsSuggestionsPromptEnabled = false
isDeviceContactsSuggestionsPromptEnabled = false,
openImagePicker = Effect.empty()
)
}
}
@@ -52,7 +52,6 @@ internal sealed interface ComposerAction : ComposerOperation {
data class RemoveAttachment(val attachmentId: AttachmentId) : ComposerAction
data object ChangeSenderRequested : ComposerAction
data object OnBottomSheetOptionSelected : ComposerAction
data object OnAddAttachments : ComposerAction
data object OnCloseComposer : ComposerAction
data object OnSendMessage : ComposerAction
@@ -24,6 +24,7 @@ sealed class ContactSuggestionUiModel(
data class Contact(
override val name: String,
val initial: String,
val email: String
) : ContactSuggestionUiModel(name)
@@ -20,7 +20,6 @@ package ch.protonmail.android.mailcomposer.presentation.reducer
import ch.protonmail.android.mailcommon.presentation.Effect
import ch.protonmail.android.mailcommon.presentation.model.TextUiModel
import ch.protonmail.android.mailmessage.domain.model.DraftAction
import ch.protonmail.android.mailcomposer.domain.model.DraftBody
import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime
import ch.protonmail.android.mailcomposer.domain.model.MessagePassword
@@ -30,12 +29,13 @@ import ch.protonmail.android.mailcomposer.presentation.model.ComposerAction
import ch.protonmail.android.mailcomposer.presentation.model.ComposerDraftState
import ch.protonmail.android.mailcomposer.presentation.model.ComposerEvent
import ch.protonmail.android.mailcomposer.presentation.model.ComposerOperation
import ch.protonmail.android.mailcomposer.presentation.model.DraftUiModel
import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel
import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel
import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel
import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField
import ch.protonmail.android.mailcomposer.presentation.model.DraftUiModel
import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType
import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel
import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel
import ch.protonmail.android.mailmessage.domain.model.DraftAction
import ch.protonmail.android.mailmessage.domain.model.MessageAttachment
import ch.protonmail.android.mailmessage.presentation.mapper.AttachmentUiModelMapper
import ch.protonmail.android.mailmessage.presentation.model.AttachmentGroupUiModel
@@ -66,8 +66,7 @@ class ComposerReducer @Inject constructor(
is ComposerAction.ContactSuggestionTermChanged -> currentState
is ComposerAction.DraftBodyChanged -> updateDraftBodyTo(currentState, this.draftBody)
is ComposerAction.SubjectChanged -> updateSubjectTo(currentState, this.subject)
is ComposerAction.OnBottomSheetOptionSelected -> updateBottomSheetVisibility(currentState, false)
is ComposerAction.OnAddAttachments -> updateBottomSheetVisibility(currentState, true)
is ComposerAction.OnAddAttachments -> updateForOnAddAttachments(currentState)
is ComposerAction.OnCloseComposer -> updateCloseComposerState(currentState, false)
is ComposerAction.ChangeSenderRequested -> currentState
is ComposerAction.OnSendMessage -> updateStateForSendMessage(currentState)
@@ -194,9 +193,6 @@ class ComposerReducer @Inject constructor(
)
}
private fun updateBottomSheetVisibility(currentState: ComposerDraftState, bottomSheetVisibility: Boolean) =
currentState.copy(changeBottomSheetVisibility = Effect.of(bottomSheetVisibility))
private fun updateComposerFieldsState(
currentState: ComposerDraftState,
draftUiModel: DraftUiModel,
@@ -336,6 +332,10 @@ class ComposerReducer @Inject constructor(
messageExpirationTime: MessageExpirationTime?
) = currentState.copy(messageExpiresIn = messageExpirationTime?.expiresIn ?: Duration.ZERO)
private fun updateForOnAddAttachments(currentState: ComposerDraftState) = currentState.copy(
openImagePicker = Effect.of(Unit)
)
private fun updateRecipientsTo(
currentState: ComposerDraftState,
recipients: List<RecipientUiModel>
@@ -405,15 +405,7 @@ class ComposerReducer @Inject constructor(
val hasDuplicates = hasDuplicates(capturedToDuplicates, capturedCcDuplicates, capturedBccDuplicates)
val error = when {
hasDuplicates -> {
Effect.of(
TextUiModel(
R.string.composer_error_duplicate_recipient,
getDuplicateEmailsError(capturedToDuplicates, capturedCcDuplicates, capturedBccDuplicates)
)
)
}
hasDuplicates -> { Effect.of(TextUiModel(R.string.composer_error_duplicate_recipient)) }
hasInvalidRecipients -> Effect.of(TextUiModel(R.string.composer_error_invalid_email))
else -> Effect.empty()
}
@@ -424,7 +416,7 @@ class ComposerReducer @Inject constructor(
cc = capturedCcDuplicates.cleanedRecipients,
bcc = capturedBccDuplicates.cleanedRecipients
),
error = error,
recipientValidationError = error,
isSubmittable = allValid && notEmpty
)
}
@@ -437,23 +429,6 @@ class ComposerReducer @Inject constructor(
capturedCcDuplicates.duplicatesFound.isNotEmpty() ||
capturedBccDuplicates.duplicatesFound.isNotEmpty()
private fun getDuplicateEmailsError(
capturedToDuplicates: CleanedRecipients,
capturedCcDuplicates: CleanedRecipients,
capturedBccDuplicates: CleanedRecipients
): String {
val duplicates = capturedToDuplicates.duplicatesFound +
capturedCcDuplicates.duplicatesFound +
capturedBccDuplicates.duplicatesFound
val validDuplicates = duplicates.filterIsInstance<RecipientUiModel.Valid>()
val inValidDuplicates = duplicates.filterIsInstance<RecipientUiModel.Invalid>()
return (validDuplicates.map { it.address } + inValidDuplicates.map { it.address })
.distinct()
.joinToString(", ")
}
private fun captureDuplicateEmails(recipients: List<RecipientUiModel>): CleanedRecipients {
val itemsCounted = recipients.groupingBy { it }.eachCount()
return CleanedRecipients(
@@ -1,78 +0,0 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.mailcomposer.presentation.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import ch.protonmail.android.mailcommon.presentation.NO_CONTENT_DESCRIPTION
import ch.protonmail.android.mailcomposer.presentation.R
import me.proton.core.compose.component.ProtonRawListItem
import me.proton.core.compose.theme.ProtonDimens
@Composable
fun AddAttachmentsBottomSheetContent(onImportFromSelected: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.testTag(AddAttachmentsBottomSheetTestTags.RootItem)
.padding(vertical = ProtonDimens.DefaultSpacing)
) {
ProtonRawListItem(
modifier = Modifier
.testTag(AddAttachmentsBottomSheetTestTags.ImportEntry)
.clickable { onImportFromSelected() }
.height(ProtonDimens.ListItemHeight)
.padding(horizontal = ProtonDimens.DefaultSpacing)
) {
Icon(
modifier = Modifier.testTag(AddAttachmentsBottomSheetTestTags.ImportIcon),
painter = painterResource(id = R.drawable.ic_proton_folder_open),
contentDescription = NO_CONTENT_DESCRIPTION
)
Spacer(modifier = Modifier.width(ProtonDimens.DefaultSpacing))
Text(
modifier = Modifier
.testTag(AddAttachmentsBottomSheetTestTags.ImportText)
.weight(1f),
text = stringResource(id = R.string.composer_add_attachments_bottom_sheet_import_from),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
object AddAttachmentsBottomSheetTestTags {
const val RootItem = "AttachmentsBottomSheetRootItem"
const val ImportEntry = "ImportEntry"
const val ImportIcon = "ImportIcon"
const val ImportText = "ImportText"
}
@@ -18,57 +18,33 @@
package ch.protonmail.android.mailcomposer.presentation.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect
import ch.protonmail.android.mailcommon.presentation.Effect
import ch.protonmail.android.mailcommon.presentation.compose.FocusableForm
import ch.protonmail.android.mailcommon.presentation.model.TextUiModel
import ch.protonmail.android.mailcommon.presentation.model.string
import ch.protonmail.android.mailcommon.presentation.ui.MailDivider
import ch.protonmail.android.mailcomposer.presentation.R
import ch.protonmail.android.mailcomposer.presentation.model.ComposerFields
import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel
import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField
import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType
import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel
import ch.protonmail.android.uicomponents.chips.ChipItem
import ch.protonmail.android.uicomponents.chips.ChipsListField
import ch.protonmail.android.uicomponents.chips.ContactSuggestionItem
import ch.protonmail.android.uicomponents.chips.ContactSuggestionState
import ch.protonmail.android.uicomponents.chips.thenIf
import ch.protonmail.android.uicomponents.keyboardVisibilityAsState
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
import timber.log.Timber
@Composable
internal fun ComposerForm(
newContactSuggestionsEnabled: Boolean,
emailValidator: (String) -> Boolean,
recipientsOpen: Boolean,
initialFocus: FocusedFieldType,
@@ -83,13 +59,18 @@ internal fun ComposerForm(
) {
val isKeyboardVisible by keyboardVisibilityAsState()
val keyboardController = LocalSoftwareKeyboardController.current
val maxWidthModifier = Modifier.fillMaxWidth()
val emailNextKeyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Email
)
val recipientsButtonRotation = remember { Animatable(0F) }
var showSubjectAndBody by remember { mutableStateOf(true) }
// Handle visibility of body and subject here, to avoid issues with focus requesters.
LaunchedEffect(areContactSuggestionsExpanded, newContactSuggestionsEnabled) {
showSubjectAndBody = if (newContactSuggestionsEnabled) {
!areContactSuggestionsExpanded.any { it.value }
} else {
true
}
}
FocusableForm(
fieldList = listOf(
@@ -123,188 +104,57 @@ internal fun ComposerForm(
actions.onChangeSender
)
MailDivider()
Row(
modifier = maxWidthModifier,
horizontalArrangement = Arrangement.SpaceBetween
) {
ChipsListField(
label = stringResource(id = R.string.to_prefix),
value = fields.to.map { it.toChipItem() },
chipValidator = emailValidator,
modifier = Modifier
.weight(1f)
.padding(start = ProtonDimens.DefaultSpacing)
.testTag(ComposerTestTags.ToRecipient)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.TO),
keyboardOptions = emailNextKeyboardOptions,
focusRequester = fieldFocusRequesters[FocusedFieldType.TO],
actions = ChipsListField.Actions(
onSuggestionTermTyped = {
actions.onContactSuggestionTermChanged(it, ContactSuggestionsField.TO)
},
onSuggestionsDismissed = { actions.onContactSuggestionsDismissed(ContactSuggestionsField.TO) },
onListChanged = {
actions.onToChanged(it.mapNotNull { chipItem -> chipItem.toRecipientUiModel() })
}
),
contactSuggestionState = ContactSuggestionState(
areSuggestionsExpanded = areContactSuggestionsExpanded[ContactSuggestionsField.TO] ?: false,
contactSuggestionItems = contactSuggestions[ContactSuggestionsField.TO]?.map {
it.toSuggestionContactItem()
} ?: emptyList()
)
if (newContactSuggestionsEnabled) {
RecipientFields2(
fields = fields,
fieldFocusRequesters = fieldFocusRequesters,
recipientsOpen = recipientsOpen,
emailValidator = emailValidator,
contactSuggestions = contactSuggestions,
areContactSuggestionsExpanded = areContactSuggestionsExpanded,
actions = actions
)
Spacer(modifier = Modifier.size(ProtonDimens.DefaultSpacing))
IconButton(
modifier = Modifier.focusProperties { canFocus = false },
onClick = { actions.onToggleRecipients(!recipientsOpen) }
) {
Icon(
modifier = Modifier
.thenIf(recipientsButtonRotation.value == RecipientsButtonRotationValues.Closed) {
testTag(ComposerTestTags.ExpandCollapseArrow)
}
.thenIf(recipientsButtonRotation.value == RecipientsButtonRotationValues.Open) {
testTag(ComposerTestTags.CollapseExpandArrow)
}
.rotate(recipientsButtonRotation.value),
imageVector = Icons.Filled.KeyboardArrowUp,
tint = ProtonTheme.colors.textWeak,
contentDescription = stringResource(id = R.string.composer_expand_recipients_button)
} else {
RecipientFields(
fields = fields,
fieldFocusRequesters = fieldFocusRequesters,
recipientsOpen = recipientsOpen,
emailValidator = emailValidator,
contactSuggestions = contactSuggestions,
areContactSuggestionsExpanded = areContactSuggestionsExpanded,
actions = actions
)
}
if (showSubjectAndBody) {
MailDivider()
SubjectTextField(
initialValue = fields.subject,
onSubjectChange = actions.onSubjectChanged,
modifier = maxWidthModifier
.testTag(ComposerTestTags.Subject)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.SUBJECT)
)
MailDivider()
BodyTextField(
initialValue = fields.body,
shouldRequestFocus = shouldForceBodyTextFocus,
replaceDraftBody = replaceDraftBody,
onBodyChange = actions.onBodyChanged,
modifier = maxWidthModifier
.testTag(ComposerTestTags.MessageBody)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.BODY)
)
if (fields.quotedBody != null) {
RespondInlineButton(actions.onRespondInline)
BodyHtmlQuote(
value = fields.quotedBody.styled.value,
modifier = maxWidthModifier.testTag(ComposerTestTags.MessageHtmlQuotedBody)
)
}
}
AnimatedVisibility(
visible = recipientsOpen,
modifier = Modifier.animateContentSize()
) {
Column {
MailDivider()
ChipsListField(
label = stringResource(id = R.string.cc_prefix),
value = fields.cc.map { it.toChipItem() },
chipValidator = emailValidator,
modifier = Modifier
.padding(start = ProtonDimens.DefaultSpacing)
.testTag(ComposerTestTags.CcRecipient)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.CC),
keyboardOptions = emailNextKeyboardOptions,
focusRequester = fieldFocusRequesters[FocusedFieldType.CC],
actions = ChipsListField.Actions(
onSuggestionTermTyped = {
actions.onContactSuggestionTermChanged(it, ContactSuggestionsField.CC)
},
onSuggestionsDismissed = {
actions.onContactSuggestionsDismissed(ContactSuggestionsField.CC)
},
onListChanged = {
actions.onCcChanged(it.mapNotNull { chipItem -> chipItem.toRecipientUiModel() })
}
),
contactSuggestionState = ContactSuggestionState(
areSuggestionsExpanded = areContactSuggestionsExpanded[ContactSuggestionsField.CC] ?: false,
contactSuggestionItems = contactSuggestions[ContactSuggestionsField.CC]?.map {
it.toSuggestionContactItem()
} ?: emptyList()
)
)
MailDivider()
ChipsListField(
label = stringResource(id = R.string.bcc_prefix),
value = fields.bcc.map { it.toChipItem() },
chipValidator = emailValidator,
modifier = Modifier
.padding(start = ProtonDimens.DefaultSpacing)
.testTag(ComposerTestTags.BccRecipient)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.BCC),
keyboardOptions = emailNextKeyboardOptions,
focusRequester = fieldFocusRequesters[FocusedFieldType.BCC],
actions = ChipsListField.Actions(
onSuggestionTermTyped = {
actions.onContactSuggestionTermChanged(it, ContactSuggestionsField.BCC)
},
onSuggestionsDismissed = {
actions.onContactSuggestionsDismissed(ContactSuggestionsField.BCC)
},
onListChanged = {
actions.onBccChanged(it.mapNotNull { chipItem -> chipItem.toRecipientUiModel() })
}
),
contactSuggestionState = ContactSuggestionState(
areSuggestionsExpanded = areContactSuggestionsExpanded[ContactSuggestionsField.BCC]
?: false,
contactSuggestionItems = contactSuggestions[ContactSuggestionsField.BCC]?.map {
it.toSuggestionContactItem()
} ?: emptyList()
)
)
}
}
MailDivider()
SubjectTextField(
initialValue = fields.subject,
onSubjectChange = actions.onSubjectChanged,
modifier = maxWidthModifier
.testTag(ComposerTestTags.Subject)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.SUBJECT)
)
MailDivider()
BodyTextField(
initialValue = fields.body,
shouldRequestFocus = shouldForceBodyTextFocus,
replaceDraftBody = replaceDraftBody,
onBodyChange = actions.onBodyChanged,
modifier = maxWidthModifier
.testTag(ComposerTestTags.MessageBody)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.BODY)
)
if (fields.quotedBody != null) {
RespondInlineButton(actions.onRespondInline)
BodyHtmlQuote(
value = fields.quotedBody.styled.value,
modifier = maxWidthModifier.testTag(ComposerTestTags.MessageHtmlQuotedBody)
)
}
}
}
LaunchedEffect(key1 = recipientsOpen) {
recipientsButtonRotation.animateTo(
if (recipientsOpen) RecipientsButtonRotationValues.Open else RecipientsButtonRotationValues.Closed
)
}
}
private object RecipientsButtonRotationValues {
const val Open = 180f
const val Closed = 0f
}
private fun ChipItem.toRecipientUiModel(): RecipientUiModel? = when (this) {
is ChipItem.Counter -> null
is ChipItem.Invalid -> RecipientUiModel.Invalid(value)
is ChipItem.Valid -> RecipientUiModel.Valid(value)
}
private fun RecipientUiModel.toChipItem(): ChipItem = when (this) {
is RecipientUiModel.Invalid -> ChipItem.Invalid(address)
is RecipientUiModel.Valid -> ChipItem.Valid(address)
}
@Composable
private fun ContactSuggestionUiModel.toSuggestionContactItem(): ContactSuggestionItem = when (this) {
is ContactSuggestionUiModel.Contact -> ContactSuggestionItem(
this.name,
this.email,
listOf(this.email)
)
is ContactSuggestionUiModel.ContactGroup -> ContactSuggestionItem(
this.name,
TextUiModel.PluralisedText(
value = R.plurals.composer_recipient_suggestion_contacts,
count = this.emails.size
).string(),
this.emails
)
}
@@ -20,6 +20,7 @@ package ch.protonmail.android.mailcomposer.presentation.ui
import android.Manifest
import android.text.format.Formatter
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -97,7 +98,7 @@ fun ComposerScreen(actions: ComposerScreen.Actions, viewModel: ComposerViewModel
mutableStateOf(if (state.fields.to.isEmpty()) FocusedFieldType.TO else FocusedFieldType.BODY)
}
val snackbarHostState = remember { ProtonSnackbarHostState() }
val bottomSheetType = rememberSaveable { mutableStateOf(BottomSheetType.AddAttachments) }
val bottomSheetType = rememberSaveable { mutableStateOf(BottomSheetType.ChangeSender) }
val bottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val attachmentSizeDialogState = remember { mutableStateOf(false) }
val sendingErrorDialogState = remember { mutableStateOf<String?>(null) }
@@ -151,16 +152,13 @@ fun ComposerScreen(actions: ComposerScreen.Actions, viewModel: ComposerViewModel
}
}
ConsumableLaunchedEffect(effect = state.openImagePicker) {
imagePicker.launch("*/*")
}
ProtonModalBottomSheetLayout(
sheetContent = bottomSheetHeightConstrainedContent {
when (bottomSheetType.value) {
BottomSheetType.AddAttachments -> AddAttachmentsBottomSheetContent(
onImportFromSelected = {
viewModel.submit(ComposerAction.OnBottomSheetOptionSelected)
imagePicker.launch("*/*")
}
)
BottomSheetType.ChangeSender -> ChangeSenderBottomSheetContent(
state.senderAddresses,
{ sender -> viewModel.submit(ComposerAction.SenderChanged(sender)) }
@@ -180,7 +178,6 @@ fun ComposerScreen(actions: ComposerScreen.Actions, viewModel: ComposerViewModel
ComposerTopBar(
attachmentsCount = state.attachments.attachments.size,
onAddAttachmentsClick = {
bottomSheetType.value = BottomSheetType.AddAttachments
viewModel.submit(ComposerAction.OnAddAttachments)
},
onCloseComposerClick = {
@@ -245,7 +242,8 @@ fun ComposerScreen(actions: ComposerScreen.Actions, viewModel: ComposerViewModel
{ bottomSheetType.value = it }
),
contactSuggestions = state.contactSuggestions,
areContactSuggestionsExpanded = state.areContactSuggestionsExpanded
areContactSuggestionsExpanded = state.areContactSuggestionsExpanded,
newContactSuggestionsEnabled = viewModel.newContactSuggestionsEnabled
)
if (state.attachments.attachments.isNotEmpty()) {
AttachmentFooter(
@@ -335,6 +333,10 @@ fun ComposerScreen(actions: ComposerScreen.Actions, viewModel: ComposerViewModel
)
}
ConsumableTextEffect(effect = state.recipientValidationError) { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
ConsumableTextEffect(effect = state.premiumFeatureMessage) { message ->
snackbarHostState.showSnackbar(type = ProtonSnackbarType.NORM, message = message)
}
@@ -473,7 +475,7 @@ object ComposerScreen {
}
}
private enum class BottomSheetType { AddAttachments, ChangeSender, SetExpirationTime }
private enum class BottomSheetType { ChangeSender, SetExpirationTime }
private data class SendExpiringMessageDialogState(
val isVisible: Boolean,
@@ -19,13 +19,12 @@
package ch.protonmail.android.mailcomposer.presentation.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
@@ -35,7 +34,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import ch.protonmail.android.mailcommon.presentation.NO_CONTENT_DESCRIPTION
@@ -87,19 +85,17 @@ internal fun PrefixedEmailSelector(
@Composable
private fun ChangeSenderButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
Icon(
IconButton(
modifier = modifier
.padding(horizontal = ProtonDimens.MediumSpacing)
.testTag(ComposerTestTags.ChangeSenderButton)
.clickable(
onClickLabel = stringResource(id = R.string.change_sender_button_content_description),
role = Role.Button,
onClick = onClick
),
painter = painterResource(id = R.drawable.ic_proton_three_dots_vertical),
tint = ProtonTheme.colors.iconWeak,
contentDescription = NO_CONTENT_DESCRIPTION
)
.testTag(ComposerTestTags.ChangeSenderButton),
onClick = onClick
) {
Icon(
painter = painterResource(id = R.drawable.ic_proton_three_dots_vertical),
tint = ProtonTheme.colors.iconWeak,
contentDescription = NO_CONTENT_DESCRIPTION
)
}
}
object PrefixedEmailSelectorTestTags {
@@ -0,0 +1,241 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.mailcomposer.presentation.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import ch.protonmail.android.mailcommon.presentation.compose.FocusableFormScope
import ch.protonmail.android.mailcommon.presentation.model.TextUiModel
import ch.protonmail.android.mailcommon.presentation.model.string
import ch.protonmail.android.mailcommon.presentation.ui.MailDivider
import ch.protonmail.android.mailcomposer.presentation.R
import ch.protonmail.android.mailcomposer.presentation.model.ComposerFields
import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel
import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField
import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType
import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel
import ch.protonmail.android.uicomponents.chips.ChipsListField
import ch.protonmail.android.uicomponents.chips.ContactSuggestionItem
import ch.protonmail.android.uicomponents.chips.ContactSuggestionState
import ch.protonmail.android.uicomponents.chips.item.ChipItem
import ch.protonmail.android.uicomponents.chips.thenIf
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
@Composable
internal fun FocusableFormScope<FocusedFieldType>.RecipientFields(
modifier: Modifier = Modifier,
fields: ComposerFields,
fieldFocusRequesters: Map<FocusedFieldType, FocusRequester>,
recipientsOpen: Boolean,
emailValidator: (String) -> Boolean,
contactSuggestions: Map<ContactSuggestionsField, List<ContactSuggestionUiModel>>,
actions: ComposerFormActions,
areContactSuggestionsExpanded: Map<ContactSuggestionsField, Boolean>
) {
val emailNextKeyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Email
)
val recipientsButtonRotation = remember { Animatable(0F) }
val hasCcBccContent = fields.cc.isNotEmpty() || fields.bcc.isNotEmpty()
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
ChipsListField(
label = stringResource(id = R.string.to_prefix),
value = fields.to.map { it.toChipItem() },
chipValidator = emailValidator,
modifier = Modifier
.weight(1f)
.padding(start = ProtonDimens.DefaultSpacing)
.testTag(ComposerTestTags.ToRecipient)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.TO),
keyboardOptions = emailNextKeyboardOptions,
focusRequester = fieldFocusRequesters[FocusedFieldType.TO],
actions = ChipsListField.Actions(
onSuggestionTermTyped = {
actions.onContactSuggestionTermChanged(it, ContactSuggestionsField.TO)
},
onSuggestionsDismissed = { actions.onContactSuggestionsDismissed(ContactSuggestionsField.TO) },
onListChanged = {
actions.onToChanged(it.mapNotNull { chipItem -> chipItem.toRecipientUiModel() })
}
),
contactSuggestionState = ContactSuggestionState(
areSuggestionsExpanded = areContactSuggestionsExpanded[ContactSuggestionsField.TO] ?: false,
contactSuggestionItems = contactSuggestions[ContactSuggestionsField.TO]?.map {
it.toSuggestionContactItem()
} ?: emptyList()
)
)
Spacer(modifier = Modifier.size(ProtonDimens.DefaultSpacing))
if (!hasCcBccContent) {
IconButton(
modifier = Modifier.focusProperties { canFocus = false },
onClick = { actions.onToggleRecipients(!recipientsOpen) }
) {
Icon(
modifier = Modifier
.thenIf(recipientsButtonRotation.value == RecipientsButtonRotationValues.Closed) {
testTag(ComposerTestTags.ExpandCollapseArrow)
}
.thenIf(recipientsButtonRotation.value == RecipientsButtonRotationValues.Open) {
testTag(ComposerTestTags.CollapseExpandArrow)
}
.rotate(recipientsButtonRotation.value)
.size(ProtonDimens.SmallIconSize),
imageVector = ImageVector.vectorResource(
id = me.proton.core.presentation.R.drawable.ic_proton_chevron_down_filled
),
tint = ProtonTheme.colors.textWeak,
contentDescription = stringResource(id = R.string.composer_expand_recipients_button)
)
}
}
}
if (recipientsOpen || hasCcBccContent) {
Column {
MailDivider()
ChipsListField(
label = stringResource(id = R.string.cc_prefix),
value = fields.cc.map { it.toChipItem() },
chipValidator = emailValidator,
modifier = Modifier
.padding(start = ProtonDimens.DefaultSpacing)
.testTag(ComposerTestTags.CcRecipient)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.CC),
keyboardOptions = emailNextKeyboardOptions,
focusRequester = fieldFocusRequesters[FocusedFieldType.CC],
actions = ChipsListField.Actions(
onSuggestionTermTyped = {
actions.onContactSuggestionTermChanged(it, ContactSuggestionsField.CC)
},
onSuggestionsDismissed = {
actions.onContactSuggestionsDismissed(ContactSuggestionsField.CC)
},
onListChanged = {
actions.onCcChanged(it.mapNotNull { chipItem -> chipItem.toRecipientUiModel() })
}
),
contactSuggestionState = ContactSuggestionState(
areSuggestionsExpanded = areContactSuggestionsExpanded[ContactSuggestionsField.CC] ?: false,
contactSuggestionItems = contactSuggestions[ContactSuggestionsField.CC]?.map {
it.toSuggestionContactItem()
} ?: emptyList()
)
)
MailDivider()
ChipsListField(
label = stringResource(id = R.string.bcc_prefix),
value = fields.bcc.map { it.toChipItem() },
chipValidator = emailValidator,
modifier = Modifier
.padding(start = ProtonDimens.DefaultSpacing)
.testTag(ComposerTestTags.BccRecipient)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.BCC),
keyboardOptions = emailNextKeyboardOptions,
focusRequester = fieldFocusRequesters[FocusedFieldType.BCC],
actions = ChipsListField.Actions(
onSuggestionTermTyped = {
actions.onContactSuggestionTermChanged(it, ContactSuggestionsField.BCC)
},
onSuggestionsDismissed = {
actions.onContactSuggestionsDismissed(ContactSuggestionsField.BCC)
},
onListChanged = {
actions.onBccChanged(it.mapNotNull { chipItem -> chipItem.toRecipientUiModel() })
}
),
contactSuggestionState = ContactSuggestionState(
areSuggestionsExpanded = areContactSuggestionsExpanded[ContactSuggestionsField.BCC] ?: false,
contactSuggestionItems = contactSuggestions[ContactSuggestionsField.BCC]?.map {
it.toSuggestionContactItem()
} ?: emptyList()
)
)
}
}
LaunchedEffect(key1 = recipientsOpen) {
recipientsButtonRotation.animateTo(
if (recipientsOpen) RecipientsButtonRotationValues.Open else RecipientsButtonRotationValues.Closed
)
}
}
private object RecipientsButtonRotationValues {
const val Open = 180f
const val Closed = 0f
}
private fun ChipItem.toRecipientUiModel(): RecipientUiModel? = when (this) {
is ChipItem.Counter -> null
is ChipItem.Invalid -> RecipientUiModel.Invalid(value)
is ChipItem.Valid -> RecipientUiModel.Valid(value)
}
private fun RecipientUiModel.toChipItem(): ChipItem = when (this) {
is RecipientUiModel.Invalid -> ChipItem.Invalid(address)
is RecipientUiModel.Valid -> ChipItem.Valid(address)
}
@Composable
private fun ContactSuggestionUiModel.toSuggestionContactItem(): ContactSuggestionItem = when (this) {
is ContactSuggestionUiModel.Contact -> ContactSuggestionItem(
this.name,
this.email,
listOf(this.email)
)
is ContactSuggestionUiModel.ContactGroup -> ContactSuggestionItem(
this.name,
TextUiModel.PluralisedText(
value = R.plurals.composer_recipient_suggestion_contacts,
count = this.emails.size
).string(),
this.emails
)
}
@@ -0,0 +1,235 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.mailcomposer.presentation.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import ch.protonmail.android.mailcommon.presentation.compose.FocusableFormScope
import ch.protonmail.android.mailcommon.presentation.model.TextUiModel
import ch.protonmail.android.mailcommon.presentation.model.string
import ch.protonmail.android.mailcommon.presentation.ui.MailDivider
import ch.protonmail.android.mailcomposer.presentation.R
import ch.protonmail.android.mailcomposer.presentation.model.ComposerFields
import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel
import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField
import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType
import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel
import ch.protonmail.android.uicomponents.chips.ChipsListField2
import ch.protonmail.android.uicomponents.chips.ContactSuggestionState2
import ch.protonmail.android.uicomponents.chips.item.ChipItem
import ch.protonmail.android.uicomponents.chips.thenIf
import ch.protonmail.android.uicomponents.composer.suggestions.ContactSuggestionItem2
import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
@Composable
internal fun FocusableFormScope<FocusedFieldType>.RecipientFields2(
modifier: Modifier = Modifier,
fields: ComposerFields,
fieldFocusRequesters: Map<FocusedFieldType, FocusRequester>,
recipientsOpen: Boolean,
emailValidator: (String) -> Boolean,
contactSuggestions: Map<ContactSuggestionsField, List<ContactSuggestionUiModel>>,
actions: ComposerFormActions,
areContactSuggestionsExpanded: Map<ContactSuggestionsField, Boolean>
) {
val recipientsButtonRotation = remember { Animatable(0F) }
val isShowingToSuggestions = areContactSuggestionsExpanded[ContactSuggestionsField.TO] == true
val isShowingCcSuggestions = areContactSuggestionsExpanded[ContactSuggestionsField.CC] == true
val hasCcBccContent = fields.cc.isNotEmpty() || fields.bcc.isNotEmpty()
val shouldShowCcBcc = recipientsOpen || hasCcBccContent
Row(
modifier = modifier.fillMaxWidth()
) {
ChipsListField2(
label = stringResource(id = R.string.to_prefix),
value = fields.to.map { it.toChipItem() },
chipValidator = emailValidator,
modifier = Modifier
.weight(1f)
.testTag(ComposerTestTags.ToRecipient)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.TO),
focusRequester = fieldFocusRequesters[FocusedFieldType.TO],
actions = ChipsListField2.Actions(
onSuggestionTermTyped = {
actions.onContactSuggestionTermChanged(it, ContactSuggestionsField.TO)
},
onSuggestionsDismissed = { actions.onContactSuggestionsDismissed(ContactSuggestionsField.TO) },
onListChanged = {
actions.onToChanged(it.mapNotNull { chipItem -> chipItem.toRecipientUiModel() })
}
),
contactSuggestionState = ContactSuggestionState2(
areSuggestionsExpanded = areContactSuggestionsExpanded[ContactSuggestionsField.TO] ?: false,
contactSuggestionItems = contactSuggestions[ContactSuggestionsField.TO]?.map {
it.toSuggestionContactItem()
} ?: emptyList()
),
chevronIconContent = {
if (!hasCcBccContent) {
IconButton(
modifier = Modifier
.align(Alignment.Top)
.focusProperties { canFocus = false },
onClick = { actions.onToggleRecipients(!recipientsOpen) }
) {
Icon(
modifier = Modifier
.thenIf(recipientsButtonRotation.value == RecipientsButtonRotationValues2.Closed) {
testTag(ComposerTestTags.ExpandCollapseArrow)
}
.thenIf(recipientsButtonRotation.value == RecipientsButtonRotationValues2.Open) {
testTag(ComposerTestTags.CollapseExpandArrow)
}
.rotate(recipientsButtonRotation.value)
.size(ProtonDimens.SmallIconSize),
imageVector = ImageVector.vectorResource(
id = me.proton.core.presentation.R.drawable.ic_proton_chevron_down_filled
),
tint = ProtonTheme.colors.textWeak,
contentDescription = stringResource(id = R.string.composer_expand_recipients_button)
)
}
}
}
)
}
if (shouldShowCcBcc && !isShowingToSuggestions) {
Column {
MailDivider()
ChipsListField2(
label = stringResource(id = R.string.cc_prefix),
value = fields.cc.map { it.toChipItem() },
chipValidator = emailValidator,
modifier = Modifier
.testTag(ComposerTestTags.CcRecipient)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.CC),
focusRequester = fieldFocusRequesters[FocusedFieldType.CC],
actions = ChipsListField2.Actions(
onSuggestionTermTyped = {
actions.onContactSuggestionTermChanged(it, ContactSuggestionsField.CC)
},
onSuggestionsDismissed = {
actions.onContactSuggestionsDismissed(ContactSuggestionsField.CC)
},
onListChanged = {
actions.onCcChanged(it.mapNotNull { chipItem -> chipItem.toRecipientUiModel() })
}
),
contactSuggestionState = ContactSuggestionState2(
areSuggestionsExpanded = areContactSuggestionsExpanded[ContactSuggestionsField.CC] ?: false,
contactSuggestionItems = contactSuggestions[ContactSuggestionsField.CC]?.map {
it.toSuggestionContactItem()
} ?: emptyList()
)
)
if (!isShowingCcSuggestions) {
MailDivider()
ChipsListField2(
label = stringResource(id = R.string.bcc_prefix),
value = fields.bcc.map { it.toChipItem() },
chipValidator = emailValidator,
modifier = Modifier
.testTag(ComposerTestTags.BccRecipient)
.retainFieldFocusOnConfigurationChange(FocusedFieldType.BCC),
focusRequester = fieldFocusRequesters[FocusedFieldType.BCC],
actions = ChipsListField2.Actions(
onSuggestionTermTyped = {
actions.onContactSuggestionTermChanged(it, ContactSuggestionsField.BCC)
},
onSuggestionsDismissed = {
actions.onContactSuggestionsDismissed(ContactSuggestionsField.BCC)
},
onListChanged = {
actions.onBccChanged(it.mapNotNull { chipItem -> chipItem.toRecipientUiModel() })
}
),
contactSuggestionState = ContactSuggestionState2(
areSuggestionsExpanded = areContactSuggestionsExpanded[ContactSuggestionsField.BCC] ?: false,
contactSuggestionItems = contactSuggestions[ContactSuggestionsField.BCC]?.map {
it.toSuggestionContactItem()
} ?: emptyList()
)
)
}
}
}
LaunchedEffect(key1 = recipientsOpen) {
recipientsButtonRotation.animateTo(
if (recipientsOpen) RecipientsButtonRotationValues2.Open else RecipientsButtonRotationValues2.Closed
)
}
}
private object RecipientsButtonRotationValues2 {
const val Open = 180f
const val Closed = 0f
}
private fun ChipItem.toRecipientUiModel(): RecipientUiModel? = when (this) {
is ChipItem.Counter -> null
is ChipItem.Invalid -> RecipientUiModel.Invalid(value)
is ChipItem.Valid -> RecipientUiModel.Valid(value)
}
private fun RecipientUiModel.toChipItem(): ChipItem = when (this) {
is RecipientUiModel.Invalid -> ChipItem.Invalid(address)
is RecipientUiModel.Valid -> ChipItem.Valid(address)
}
@Composable
private fun ContactSuggestionUiModel.toSuggestionContactItem(): ContactSuggestionItem2 = when (this) {
is ContactSuggestionUiModel.Contact -> ContactSuggestionItem2.ContactSuggestionItem(
this.initial,
this.name,
this.email,
this.email
)
is ContactSuggestionUiModel.ContactGroup -> ContactSuggestionItem2.ContactGroupSuggestionItem(
this.name,
TextUiModel.PluralisedText(
value = R.plurals.composer_recipient_suggestion_contacts,
count = this.emails.size
).string(),
this.emails
)
}
@@ -18,65 +18,73 @@
package ch.protonmail.android.mailcomposer.presentation.usecase
import ch.protonmail.android.mailcommon.domain.coroutines.DefaultDispatcher
import ch.protonmail.android.mailcommon.presentation.usecase.GetInitials
import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel
import ch.protonmail.android.mailcontact.domain.model.Contact
import ch.protonmail.android.mailcontact.domain.model.ContactGroup
import ch.protonmail.android.mailcontact.domain.model.DeviceContact
import ch.protonmail.android.mailcontact.domain.model.Contact
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.proton.core.util.kotlin.takeIfNotBlank
import javax.inject.Inject
class SortContactsForSuggestions @Inject constructor() {
class SortContactsForSuggestions @Inject constructor(
private val getInitials: GetInitials,
@DefaultDispatcher private val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
operator fun invoke(
suspend operator fun invoke(
contacts: List<Contact>,
deviceContacts: List<DeviceContact>,
contactGroups: List<ContactGroup>,
maxContactAutocompletionCount: Int
): List<ContactSuggestionUiModel> {
): List<ContactSuggestionUiModel> = withContext(dispatcher) {
// Use a temporary map to store unique contacts based on their email address.
val temporaryEmailContactMap = mutableMapOf<String, ContactSuggestionUiModel.Contact>()
val fromContacts = contacts.asSequence().flatMap { contact ->
contact.contactEmails.map {
contact.copy(
contactEmails = listOf(it)
) // flatMap into Contacts containing only one ContactEmail because we need to sort by them
contact.contactEmails.map { contactEmail ->
Triple(contact, contactEmail, Long.MAX_VALUE - contactEmail.lastUsedTime)
}
}.sortedBy {
val lastUsedTimeDescending = Long.MAX_VALUE - it.contactEmails.first().lastUsedTime
}.sortedBy { (contact, contactEmail, lastUsedTimeDescending) ->
"$lastUsedTimeDescending ${contact.name} ${contactEmail.email}"
}.mapNotNull { (contact, contactEmail, _) ->
val email = contactEmail.email
if (email in temporaryEmailContactMap) return@mapNotNull null
// LastUsedTime, name, email
"$lastUsedTimeDescending ${it.name} ${it.contactEmails.first().email ?: ""}"
}.map { contact ->
val contactEmail = contact.contactEmails.first()
ContactSuggestionUiModel.Contact(
name = contactEmail.name.takeIfNotBlank()
?: contact.name.takeIfNotBlank()
?: contactEmail.email,
email = contactEmail.email
)
?: email,
initial = getInitials(contact.name).takeIfNotBlank() ?: "?",
email = email
).also { temporaryEmailContactMap[email] = it }
}
val fromDeviceContacts = deviceContacts.asSequence().map {
val fromDeviceContacts = deviceContacts.asSequence().mapNotNull { deviceContact ->
val email = deviceContact.email
if (email in temporaryEmailContactMap) return@mapNotNull null
ContactSuggestionUiModel.Contact(
name = it.name,
email = it.email
)
name = deviceContact.name,
initial = getInitials(deviceContact.name).takeIfNotBlank() ?: "?",
email = email
).also { temporaryEmailContactMap[email] = it }
}
val fromContactGroups = contactGroups.asSequence().map { contactGroup ->
ContactSuggestionUiModel.ContactGroup(
name = contactGroup.name,
emails = contactGroup.members.map { it.email }
emails = contactGroup.members.map { it.email }.distinct()
)
}
val fromDeviceAndContactGroups = (fromDeviceContacts + fromContactGroups).sortedBy {
it.name
}
val fromDeviceAndContactGroups = (fromDeviceContacts + fromContactGroups).sortedBy { it.name }
return (fromContacts + fromDeviceAndContactGroups)
return@withContext (fromContacts + fromDeviceAndContactGroups)
.take(maxContactAutocompletionCount)
.toList()
}
}
@@ -30,6 +30,7 @@ import ch.protonmail.android.mailcommon.domain.model.hasEmailData
import ch.protonmail.android.mailcommon.domain.usecase.GetPrimaryAddress
import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId
import ch.protonmail.android.mailcommon.presentation.model.TextUiModel
import ch.protonmail.android.mailcomposer.domain.annotations.NewContactSuggestionsEnabled
import ch.protonmail.android.mailcomposer.domain.model.DecryptedDraftFields
import ch.protonmail.android.mailcomposer.domain.model.DraftBody
import ch.protonmail.android.mailcomposer.domain.model.DraftFields
@@ -160,6 +161,7 @@ class ComposerViewModel @Inject constructor(
private val observeMessageExpirationTime: ObserveMessageExpirationTime,
private val getExternalRecipients: GetExternalRecipients,
private val convertHtmlToPlainText: ConvertHtmlToPlainText,
@NewContactSuggestionsEnabled private val isNewContactsSuggestionsEnabled: Boolean,
isDeviceContactsSuggestionsEnabled: IsDeviceContactsSuggestionsEnabled,
getDecryptedDraftFields: GetDecryptedDraftFields,
savedStateHandle: SavedStateHandle,
@@ -180,6 +182,9 @@ class ComposerViewModel @Inject constructor(
)
val state: StateFlow<ComposerDraftState> = mutableState
// This is a short lived FF, kept outside of the state on purpose.
val newContactSuggestionsEnabled = isNewContactsSuggestionsEnabled
init {
val inputDraftId = savedStateHandle.get<String>(ComposerScreen.DraftMessageIdKey)
val draftAction = savedStateHandle.get<String>(ComposerScreen.SerializedDraftActionKey)
@@ -401,7 +406,6 @@ class ComposerViewModel @Inject constructor(
is ComposerAction.ContactSuggestionsDismissed -> emitNewStateFor(action)
is ComposerAction.DeviceContactsPromptDenied -> onDeviceContactsPromptDenied()
is ComposerAction.OnBottomSheetOptionSelected -> emitNewStateFor(action)
is ComposerAction.OnAddAttachments -> emitNewStateFor(action)
is ComposerAction.OnCloseComposer -> emitNewStateFor(onCloseComposer(action))
is ComposerAction.OnSendMessage -> emitNewStateFor(handleOnSendMessage(action))
@@ -31,10 +31,10 @@
<string name="change_sender_button_content_description">Cambiar el remitente</string>
<string name="composer_change_sender_paid_feature">Mejore a un plan de pago para cambiar la dirección de remitente</string>
<string name="composer_error_change_sender_failed_getting_subscription">No se puede cambiar el remitente porque no pudo obtener la suscripción del usuario. Intente otra vez.</string>
<string name="composer_error_store_draft_sender_address">Error al guardar la dirección del remitente de este borrador</string>
<string name="composer_error_store_draft_sender_address">Error al guardar la dirección del remitente</string>
<string name="composer_error_store_draft_body">Error al guardar el cuerpo de este borrador</string>
<string name="composer_error_duplicate_recipient">Destinatario duplicado borrado: %1$s</string>
<string name="composer_error_store_draft_subject">Error al guardar el asunto de este borrador</string>
<string name="composer_error_duplicate_recipient">Destinatarios duplicados borrados</string>
<string name="composer_error_store_draft_subject">Error al guardar el asunto</string>
<string name="composer_error_store_draft_recipients">Error al guardar los destinatarios de este borrador</string>
<string name="composer_error_loading_draft">El contenido de este borrador no está disponible en este momento. Editarlo sobrescribirá cualquier contenido preexistente.</string>
<string name="composer_add_attachments_content_description">Añadir archivo adjunto</string>
@@ -76,7 +76,7 @@
<!-- Message sending error dialog -->
<string name="message_sending_error_dialog_header">Error de envío</string>
<string name="message_sending_error_dialog_button_dismiss">Cerrar</string>
<string name="message_sending_error_dialog_text_error">El correo electrónico no puede ser enviado al correo electrónico especificado debido a la siguiente razón:</string>
<string name="message_sending_error_dialog_text_error">No se puede enviar el correo electrónico a la dirección indicada debido a la siguiente razón:</string>
<string name="message_sending_error_dialog_text_reason_no_trusted_keys">No hay claves de confianza: %s</string>
<string name="message_sending_error_dialog_text_reason_address_disabled">La dirección no existe: %s</string>
<string name="set_message_password_title">Cifrar mensaje</string>
@@ -88,7 +88,7 @@
<string name="set_message_password_supporting_text">Longitud de 4 a 21 caracteres</string>
<string name="set_message_password_supporting_error_text">La contraseña debe tener entre 4 y 21 caracteres</string>
<string name="set_message_password_supporting_text_repeat">Las contraseñas deben coincidir</string>
<string name="set_message_password_supporting_error_text_repeat">Las dos contraseñas no coinciden</string>
<string name="set_message_password_supporting_error_text_repeat">Las contraseñas no coinciden.</string>
<string name="set_message_password_button_show">Mostrar contraseña</string>
<string name="set_message_password_button_hide">Ocultar contraseña</string>
<string name="set_message_password_button_apply">Aplicar contraseña</string>
@@ -96,7 +96,7 @@
<string name="set_message_password_button_remove_password">Borrar la contraseña</string>
<string name="respondInline">Responder entre líneas</string>
<plurals name="composer_recipient_suggestion_contacts">
<item quantity="one">%d contacto</item>
<item quantity="other">%d contactos</item>
<item quantity="one">%d miembro</item>
<item quantity="other">%d miembros</item>
</plurals>
</resources>
@@ -31,10 +31,10 @@
<string name="change_sender_button_content_description">Змяніць адпраўніка</string>
<string name="composer_change_sender_paid_feature">Перайдзіце на платны тарыфны план, каб змяняць адрас адпраўніка</string>
<string name="composer_error_change_sender_failed_getting_subscription">Немагчыма змяніць адпраўніка, бо не ўдаецца атрымаць падпіску карыстальніка. Паспрабуйце яшчэ раз.</string>
<string name="composer_error_store_draft_sender_address">Збой захавання адраса адпраўніка гэтага чарнавіка</string>
<string name="composer_error_store_draft_body">Збой захавання цела гэтага чарнавіка</string>
<string name="composer_error_duplicate_recipient">Выдалены дублікат атрымальніка: %1$s</string>
<string name="composer_error_store_draft_subject">Збой захавання тэмы гэтага чарнавіка</string>
<string name="composer_error_store_draft_sender_address">Sender address wasn\'t saved</string>
<string name="composer_error_store_draft_body">Draft email body wasn\'t saved</string>
<string name="composer_error_duplicate_recipient">Дубліраваныя атрымальнік(і) выдалены</string>
<string name="composer_error_store_draft_subject">Subject line wasn\'t saved</string>
<string name="composer_error_store_draft_recipients">Збой захавання атрымальнікаў гэтага чарнавіка</string>
<string name="composer_error_loading_draft">Змесціва гэтага чарнавіка недаступна ў цяперашні час. Рэдагаванне перавызначыць любое змесціва, якое было створана да гэтага.</string>
<string name="composer_add_attachments_content_description">Дадаць далучэнні</string>
@@ -76,7 +76,7 @@
<!-- Message sending error dialog -->
<string name="message_sending_error_dialog_header">Памылка адпраўкі</string>
<string name="message_sending_error_dialog_button_dismiss">Закрыць</string>
<string name="message_sending_error_dialog_text_error">Ваш ліст немагчыма адправіць на ўведзены адрас электроннай пошты па наступнай прычыне:</string>
<string name="message_sending_error_dialog_text_error">Your email cannot be sent to the address entered due to the following reason:</string>
<string name="message_sending_error_dialog_text_reason_no_trusted_keys">Адсутнічаюць давераныя ключы: %s</string>
<string name="message_sending_error_dialog_text_reason_address_disabled">Адрас не існуе: %s</string>
<string name="set_message_password_title">Зашыфраваць паведамленне</string>
@@ -88,7 +88,7 @@
<string name="set_message_password_supporting_text">Ад 4 да 21 сімвала</string>
<string name="set_message_password_supporting_error_text">Ваш пароль павінен мець даўжыню ад 4 да 21 сімвала</string>
<string name="set_message_password_supporting_text_repeat">Паролі павінны супадаць</string>
<string name="set_message_password_supporting_error_text_repeat">Паролі не супадаюць</string>
<string name="set_message_password_supporting_error_text_repeat">Passwords do not match</string>
<string name="set_message_password_button_show">Паказаць пароль</string>
<string name="set_message_password_button_hide">Схаваць пароль</string>
<string name="set_message_password_button_apply">Ужыць пароль</string>
@@ -96,9 +96,9 @@
<string name="set_message_password_button_remove_password">Выдаліць пароль</string>
<string name="respondInline">Адказаць з цытаваннем</string>
<plurals name="composer_recipient_suggestion_contacts">
<item quantity="one">%d кантакт</item>
<item quantity="few">%d кантакты</item>
<item quantity="many">%d кантактаў</item>
<item quantity="other">%d кантактаў</item>
<item quantity="one">%d удзельнік</item>
<item quantity="few">%d удзельнікі</item>
<item quantity="many">%d удзельнікаў</item>
<item quantity="other">%d удзельнікаў</item>
</plurals>
</resources>
@@ -31,10 +31,10 @@
<string name="change_sender_button_content_description">Canvia el remitent</string>
<string name="composer_change_sender_paid_feature">Milloreu a un pla de pagament per canviar l\'adreça del remitent.</string>
<string name="composer_error_change_sender_failed_getting_subscription">No es pot canviar el remitent perquè no s\'ha pogut obtenir la subscripció de l\'usuari. Torna-ho a provar.</string>
<string name="composer_error_store_draft_sender_address">S\'ha produït un error en desar l\'adreça del remitent..</string>
<string name="composer_error_store_draft_sender_address">S\'ha produït un error en desar l\'adreça del remitent.</string>
<string name="composer_error_store_draft_body">S\'ha produït un error en desar el cos de l\'esborrany.</string>
<string name="composer_error_duplicate_recipient">Destinatari duplicat esborrat: %1$s</string>
<string name="composer_error_store_draft_subject">S\'ha produït un error en desar l\'assumpte de l\'esborrany.</string>
<string name="composer_error_duplicate_recipient">Duplicated recipient(s) removed</string>
<string name="composer_error_store_draft_subject">S\'ha produït un error en desar l\'assumpte.</string>
<string name="composer_error_store_draft_recipients">S\'ha produït un error en desar els destinataris de l\'esborrany.</string>
<string name="composer_error_loading_draft">El contingut d\'aquest esborrany no està disponible en aquest moment. L\'edició substituirà qualsevol contingut preexistent.</string>
<string name="composer_add_attachments_content_description">Afegiu fitxers adjunts</string>
@@ -76,7 +76,7 @@
<!-- Message sending error dialog -->
<string name="message_sending_error_dialog_header">Error d\'enviament</string>
<string name="message_sending_error_dialog_button_dismiss">Tanca</string>
<string name="message_sending_error_dialog_text_error">El vostre correu electrònic no es pot enviar al correu electrònic introduït pel motiu següent:</string>
<string name="message_sending_error_dialog_text_error">El vostre correu electrònic no es pot enviar a l\'adreça introduïda pel motiu següent:</string>
<string name="message_sending_error_dialog_text_reason_no_trusted_keys">No hi ha claus de confiança: %s</string>
<string name="message_sending_error_dialog_text_reason_address_disabled">L\'adreça no existeix: %s</string>
<string name="set_message_password_title">Xifra el missatge</string>
@@ -88,7 +88,7 @@
<string name="set_message_password_supporting_text">Longitud de 4 a 21 caràcters</string>
<string name="set_message_password_supporting_error_text">La contrasenya ha de tenir entre 4 i 21 caràcters</string>
<string name="set_message_password_supporting_text_repeat">Les contrasenyes han de coincidir</string>
<string name="set_message_password_supporting_error_text_repeat">Les 2 contrasenyes no coincideixen</string>
<string name="set_message_password_supporting_error_text_repeat">Les contrasenyes no coincideixen.</string>
<string name="set_message_password_button_show">Mostra la contrasenya</string>
<string name="set_message_password_button_hide">Amaga la contrasenya</string>
<string name="set_message_password_button_apply">Aplica la contrasenya</string>
@@ -96,7 +96,7 @@
<string name="set_message_password_button_remove_password">Esborra la contrasenya</string>
<string name="respondInline">Respon en línia</string>
<plurals name="composer_recipient_suggestion_contacts">
<item quantity="one">%d contacte</item>
<item quantity="other">%d contactes</item>
<item quantity="one">%d membre</item>
<item quantity="other">%d membres</item>
</plurals>
</resources>
@@ -31,10 +31,10 @@
<string name="change_sender_button_content_description">Změnit odesílatele</string>
<string name="composer_change_sender_paid_feature">Přejděte na placený tarif pro změnu adresy odesílatele</string>
<string name="composer_error_change_sender_failed_getting_subscription">Nelze změnit odesílatele, protože selhalo získání uživatelského předplatného. Zkuste to znovu.</string>
<string name="composer_error_store_draft_sender_address">Uložení adresy odesílatele tohoto návrhu se nezdařilo</string>
<string name="composer_error_store_draft_body">Uložení těla tohoto návrhu se nezdařilo</string>
<string name="composer_error_duplicate_recipient">Odstraněn duplicitní příjemce: %1$s</string>
<string name="composer_error_store_draft_subject">Uložení předmětu tohoto návrhu se nezdařilo</string>
<string name="composer_error_store_draft_sender_address">Sender address wasn\'t saved</string>
<string name="composer_error_store_draft_body">Draft email body wasn\'t saved</string>
<string name="composer_error_duplicate_recipient">Duplicitní příjemci odebráni</string>
<string name="composer_error_store_draft_subject">Subject line wasn\'t saved</string>
<string name="composer_error_store_draft_recipients">Uložení příjemců tohoto konceptu selhalo</string>
<string name="composer_error_loading_draft">Obsah tohoto návrhu není v tuto chvíli k dispozici. Úpravy přepíší veškerý již existující obsah.</string>
<string name="composer_add_attachments_content_description">Přidat přílohy</string>
@@ -76,7 +76,7 @@
<!-- Message sending error dialog -->
<string name="message_sending_error_dialog_header">Chyba při odesílání</string>
<string name="message_sending_error_dialog_button_dismiss">Zavřít</string>
<string name="message_sending_error_dialog_text_error">Váš e-mail nelze odeslat na zadanou e-mailovou adresu z následujícího důvodu:</string>
<string name="message_sending_error_dialog_text_error">Your email cannot be sent to the address entered due to the following reason:</string>
<string name="message_sending_error_dialog_text_reason_no_trusted_keys">Neexistují žádné důvěryhodné klíče: %s</string>
<string name="message_sending_error_dialog_text_reason_address_disabled">Adresa neexistuje: %s</string>
<string name="set_message_password_title">Šifrovat zprávu</string>
@@ -88,7 +88,7 @@
<string name="set_message_password_supporting_text">4 až 21 znaků</string>
<string name="set_message_password_supporting_error_text">Heslo musí mít délku 4 až 21 znaků</string>
<string name="set_message_password_supporting_text_repeat">Hesla se musí shodovat</string>
<string name="set_message_password_supporting_error_text_repeat">Tyto 2 hesla nesouhlasí</string>
<string name="set_message_password_supporting_error_text_repeat">Passwords do not match</string>
<string name="set_message_password_button_show">Zobrazit heslo</string>
<string name="set_message_password_button_hide">Skrýt heslo</string>
<string name="set_message_password_button_apply">Použít heslo</string>
@@ -96,9 +96,9 @@
<string name="set_message_password_button_remove_password">Odstranit heslo</string>
<string name="respondInline">Odpovědět v řádku</string>
<plurals name="composer_recipient_suggestion_contacts">
<item quantity="one">%d kontakt</item>
<item quantity="few">%d kontakty</item>
<item quantity="many">%d kontaktů</item>
<item quantity="other">%d kontaktů</item>
<item quantity="one">%d člen</item>
<item quantity="few">%d členové</item>
<item quantity="many">%d členů</item>
<item quantity="other">%d členů</item>
</plurals>
</resources>
@@ -31,10 +31,10 @@
<string name="change_sender_button_content_description">Skift afsender</string>
<string name="composer_change_sender_paid_feature">Opgradér til et betalt abonnement for at ændre afsenderadresse</string>
<string name="composer_error_change_sender_failed_getting_subscription">Kan ikke ændre afsender, da hentning af brugerens abonnement mislykkedes. Prøv igen.</string>
<string name="composer_error_store_draft_sender_address">Lagring af denne kladdes afsenderadresse mislykkedes</string>
<string name="composer_error_store_draft_body">Opbevaring af denne kladdes indholdstekst mislykkedes</string>
<string name="composer_error_duplicate_recipient">Fjernet dobbelt modtager: %1$s</string>
<string name="composer_error_store_draft_subject">Lagring af denne kladdes emne mislykkedes</string>
<string name="composer_error_store_draft_sender_address">Sender address wasn\'t saved</string>
<string name="composer_error_store_draft_body">Draft email body wasn\'t saved</string>
<string name="composer_error_duplicate_recipient">Dublet-modtager(e) fjernet</string>
<string name="composer_error_store_draft_subject">Subject line wasn\'t saved</string>
<string name="composer_error_store_draft_recipients">Lagring af denne kladdes modtagere mislykkedes</string>
<string name="composer_error_loading_draft">Indholdet af denne kladde er ikke tilgængeligt på nuværende tidspunkt. Redigering vil overskrive alt allerede eksisterende indhold.</string>
<string name="composer_add_attachments_content_description">Tilføj vedhæftninger</string>
@@ -76,7 +76,7 @@
<!-- Message sending error dialog -->
<string name="message_sending_error_dialog_header">Afsendelsesfejl</string>
<string name="message_sending_error_dialog_button_dismiss">Luk</string>
<string name="message_sending_error_dialog_text_error">Din e-mail kan ikke sendes til den indtastede e-mail af følgende grund:</string>
<string name="message_sending_error_dialog_text_error">Your email cannot be sent to the address entered due to the following reason:</string>
<string name="message_sending_error_dialog_text_reason_no_trusted_keys">Der er ingen betroede nøgler: %s</string>
<string name="message_sending_error_dialog_text_reason_address_disabled">Adresse findes ikke: %s</string>
<string name="set_message_password_title">Krypter besked</string>
@@ -88,7 +88,7 @@
<string name="set_message_password_supporting_text">4 til 21 tegn lang</string>
<string name="set_message_password_supporting_error_text">Adgangskoden skal være mellem 4 og 21 tegn lang</string>
<string name="set_message_password_supporting_text_repeat">Adgangskoder skal være ens</string>
<string name="set_message_password_supporting_error_text_repeat">De to adgangskoder stemmer ikke overens</string>
<string name="set_message_password_supporting_error_text_repeat">Passwords do not match</string>
<string name="set_message_password_button_show">Vis adgangskode</string>
<string name="set_message_password_button_hide">Skjul adgangskode</string>
<string name="set_message_password_button_apply">Anvend adgangskode</string>
@@ -96,7 +96,7 @@
<string name="set_message_password_button_remove_password">Fjern adgangskode</string>
<string name="respondInline">Besvar in-line</string>
<plurals name="composer_recipient_suggestion_contacts">
<item quantity="one">%d kontakt</item>
<item quantity="other">%d kontakter</item>
<item quantity="one">%d medlem</item>
<item quantity="other">%d medlemmer</item>
</plurals>
</resources>
@@ -33,8 +33,8 @@
<string name="composer_error_change_sender_failed_getting_subscription">Fehler beim Ändern des Absenders, da das Abonnement des Benutzers nicht abgerufen werden konnte. Bitte versuche es erneut.</string>
<string name="composer_error_store_draft_sender_address">Das Speichern der Absenderadresse dieses Entwurfs ist fehlgeschlagen.</string>
<string name="composer_error_store_draft_body">Das Speichern des Inhalts dieses Entwurfs ist fehlgeschlagen.</string>
<string name="composer_error_duplicate_recipient">Doppelten Empfänger entfernt: %1$s</string>
<string name="composer_error_store_draft_subject">Das Speichern des Betreffs dieses Entwurfs ist fehlgeschlagen.</string>
<string name="composer_error_duplicate_recipient">Doppelte Empfänger entfernt</string>
<string name="composer_error_store_draft_subject">Subject line wasn\'t saved</string>
<string name="composer_error_store_draft_recipients">Das Speichern der Empfänger dieses Entwurfs ist fehlgeschlagen</string>
<string name="composer_error_loading_draft">Der Inhalt dieses Entwurfs ist zu diesem Zeitpunkt nicht verfügbar. Durch die Bearbeitung wird der bereits vorhandene Inhalt überschrieben.</string>
<string name="composer_add_attachments_content_description">Anhänge hinzufügen</string>
@@ -76,7 +76,7 @@
<!-- Message sending error dialog -->
<string name="message_sending_error_dialog_header">Sendefehler</string>
<string name="message_sending_error_dialog_button_dismiss">Schließen</string>
<string name="message_sending_error_dialog_text_error">Deine E-Mail kann aus folgendem Grund nicht an die angegebene E-Mail-Adresse gesendet werden:</string>
<string name="message_sending_error_dialog_text_error">Your email cannot be sent to the address entered due to the following reason:</string>
<string name="message_sending_error_dialog_text_reason_no_trusted_keys">Es gibt keine vertrauenswürdigen Schlüssel: %s</string>
<string name="message_sending_error_dialog_text_reason_address_disabled">Adresse existiert nicht: %s</string>
<string name="set_message_password_title">Nachricht verschlüsseln</string>
@@ -96,7 +96,7 @@
<string name="set_message_password_button_remove_password">Passwort entfernen</string>
<string name="respondInline">Eingebettet antworten</string>
<plurals name="composer_recipient_suggestion_contacts">
<item quantity="one">%d Kontakt</item>
<item quantity="other">%d Kontakte</item>
<item quantity="one">%d Mitglied</item>
<item quantity="other">%d Mitglieder</item>
</plurals>
</resources>
@@ -31,10 +31,10 @@
<string name="change_sender_button_content_description">Αλλαγή αποστολέα</string>
<string name="composer_change_sender_paid_feature">Αναβαθμίστε σε πρόγραμμα επί πληρωμή ώστε να αλλάξετε τη διεύθυνση του αποστολέα</string>
<string name="composer_error_change_sender_failed_getting_subscription">Δεν είναι δυνατή η αλλαγή αποστολέα καθώς απέτυχε η λήψη της συνδρομής του χρήστη. Δοκιμάστε ξανά.</string>
<string name="composer_error_store_draft_sender_address">Αποτυχία αποθήκευσης της διεύθυνσης αποστολέα αυτού του πρόχειρου</string>
<string name="composer_error_store_draft_body">Αποτυχία αποθήκευσης του σώματος αυτού του πρόχειρου</string>
<string name="composer_error_duplicate_recipient">Αφαιρέθηκε διπλότυπος παραλήπτης: %1$s</string>
<string name="composer_error_store_draft_subject">Η αποθήκευση αυτού του θέματος του πρόχειρου απέτυχε</string>
<string name="composer_error_store_draft_sender_address">Sender address wasn\'t saved</string>
<string name="composer_error_store_draft_body">Draft email body wasn\'t saved</string>
<string name="composer_error_duplicate_recipient">Οι διπλότυποι παραλήπτες καταργήθηκαν</string>
<string name="composer_error_store_draft_subject">Subject line wasn\'t saved</string>
<string name="composer_error_store_draft_recipients">Η αποθήκευση των παραληπτών αυτού του πρόχειρου απέτυχε</string>
<string name="composer_error_loading_draft">Το περιεχόμενο αυτού του πρόχειρου δεν είναι διαθέσιμο προς το παρόν. Η επεξεργασία θα αντικαταστήσει τυχόν προϋπάρχον περιεχόμενο.</string>
<string name="composer_add_attachments_content_description">Προσθήκη συνημμένων</string>
@@ -76,7 +76,7 @@
<!-- Message sending error dialog -->
<string name="message_sending_error_dialog_header">Σφάλμα αποστολής</string>
<string name="message_sending_error_dialog_button_dismiss">Κλείσιμο</string>
<string name="message_sending_error_dialog_text_error">Το μήνυμα σας δεν μπορεί να σταλεί στη διεύθυνση ηλεκτρονικού ταχυδρομείου που καταχωρήσατε για τον ακόλουθο λόγο:</string>
<string name="message_sending_error_dialog_text_error">Your email cannot be sent to the address entered due to the following reason:</string>
<string name="message_sending_error_dialog_text_reason_no_trusted_keys">Δεν υπάρχουν Αξιόπιστα Κλειδιά: %s</string>
<string name="message_sending_error_dialog_text_reason_address_disabled">Η διεύθυνση δεν υπάρχει: %s</string>
<string name="set_message_password_title">Κρυπτογράφηση μηνύματος</string>
@@ -88,7 +88,7 @@
<string name="set_message_password_supporting_text">μήκος 4 έως 21 χαρακτήρες</string>
<string name="set_message_password_supporting_error_text">Ο κωδικός πρόσβασης πρέπει να αποτελείται από 4 έως 21 χαρακτήρες</string>
<string name="set_message_password_supporting_text_repeat">Οι κωδικοί πρόσβασης πρέπει να ταυτίζονται</string>
<string name="set_message_password_supporting_error_text_repeat">Οι δυο κωδικοί πρόσβασης δεν είναι ταυτόσημοι</string>
<string name="set_message_password_supporting_error_text_repeat">Passwords do not match</string>
<string name="set_message_password_button_show">Εμφάνιση κωδικού πρόσβασης</string>
<string name="set_message_password_button_hide">Απόκρυψη κωδικού πρόσβασης</string>
<string name="set_message_password_button_apply">Εφαρμογή κωδικού πρόσβασης</string>
@@ -96,7 +96,7 @@
<string name="set_message_password_button_remove_password">Αφαίρεση κωδικού πρόσβασης</string>
<string name="respondInline">Απάντηση μέσα στην γραμμή</string>
<plurals name="composer_recipient_suggestion_contacts">
<item quantity="one">%d επαφή</item>
<item quantity="other">%d επαφές</item>
<item quantity="one">%d μέλος</item>
<item quantity="other">%d μέλη</item>
</plurals>
</resources>
@@ -31,10 +31,10 @@
<string name="change_sender_button_content_description">Cambiar el remitente</string>
<string name="composer_change_sender_paid_feature">Mejora a un plan de pago para cambiar la dirección de remitente</string>
<string name="composer_error_change_sender_failed_getting_subscription">No se puede cambiar el remitente porque no se ha podido obtener la suscripción del usuario. Intenta otra vez.</string>
<string name="composer_error_store_draft_sender_address">Error al guardar la dirección del remitente de este borrador</string>
<string name="composer_error_store_draft_sender_address">Error al guardar la dirección del remitente</string>
<string name="composer_error_store_draft_body">Error al guardar el cuerpo de este borrador</string>
<string name="composer_error_duplicate_recipient">Destinatario duplicado borrado: %1$s</string>
<string name="composer_error_store_draft_subject">Error al guardar el asunto de este borrador</string>
<string name="composer_error_duplicate_recipient">Se han borrado un o más destinatarios duplicados.</string>
<string name="composer_error_store_draft_subject">Error al guardar el asunto</string>
<string name="composer_error_store_draft_recipients">Error al guardar los destinatarios de este borrador</string>
<string name="composer_error_loading_draft">El contenido de este borrador no está disponible en este momento. Editarlo sobrescribirá cualquier contenido preexistente.</string>
<string name="composer_add_attachments_content_description">Añadir archivos adjuntos</string>
@@ -76,7 +76,7 @@
<!-- Message sending error dialog -->
<string name="message_sending_error_dialog_header">Error de envío</string>
<string name="message_sending_error_dialog_button_dismiss">Cerrar</string>
<string name="message_sending_error_dialog_text_error">Tu correo electrónico no puede ser enviado al correo electrónico especificado debido a la siguiente razón:</string>
<string name="message_sending_error_dialog_text_error">No se puede enviar el correo electrónico a la dirección indicada debido a la siguiente razón:</string>
<string name="message_sending_error_dialog_text_reason_no_trusted_keys">No hay claves de confianza: %s</string>
<string name="message_sending_error_dialog_text_reason_address_disabled">La dirección no existe: %s</string>
<string name="set_message_password_title">Cifrar el mensaje</string>
@@ -88,7 +88,7 @@
<string name="set_message_password_supporting_text">4 a 21 caracteres</string>
<string name="set_message_password_supporting_error_text">La contraseña debe tener entre 4 y 21 caracteres.</string>
<string name="set_message_password_supporting_text_repeat">Las contraseñas deben coincidir.</string>
<string name="set_message_password_supporting_error_text_repeat">Las dos contraseñas no coinciden</string>
<string name="set_message_password_supporting_error_text_repeat">Las contraseñas no coinciden.</string>
<string name="set_message_password_button_show">Mostrar la contraseña</string>
<string name="set_message_password_button_hide">Ocultar la contraseña</string>
<string name="set_message_password_button_apply">Aplicar la contraseña</string>
@@ -96,7 +96,7 @@
<string name="set_message_password_button_remove_password">Borrar la contraseña</string>
<string name="respondInline">Responder en línea</string>
<plurals name="composer_recipient_suggestion_contacts">
<item quantity="one">%d contacto</item>
<item quantity="other">%d contactos</item>
<item quantity="one">%d miembro</item>
<item quantity="other">%d miembros</item>
</plurals>
</resources>
@@ -31,10 +31,10 @@
<string name="change_sender_button_content_description">Modifier l\'expéditeur</string>
<string name="composer_change_sender_paid_feature">Passez à un abonnement payant pour modifier l\'adresse de l\'expéditeur.</string>
<string name="composer_error_change_sender_failed_getting_subscription">Impossible de changer l\'expéditeur : l\'abonnement de l\'utilisateur n\'a pu être obtenu. Veuillez réessayer.</string>
<string name="composer_error_store_draft_sender_address">L\'enregistrement de l\'adresse de l\'expéditeur du brouillon n\'a pas abouti.</string>
<string name="composer_error_store_draft_sender_address">L\'enregistrement de l\'adresse de l\'expéditeur n\'a pas abouti.</string>
<string name="composer_error_store_draft_body">L\'enregistrement du corps du brouillon n\'a pas abouti.</string>
<string name="composer_error_duplicate_recipient">Destinataire dupliqué retiré : %1$s</string>
<string name="composer_error_store_draft_subject">L\'enregistrement de l\'objet du brouillon n\'a pas abouti.</string>
<string name="composer_error_duplicate_recipient">Un ou plusieurs destinataires dupliqués ont été retirés.</string>
<string name="composer_error_store_draft_subject">L\'enregistrement de l\'objet n\'a pas abouti.</string>
<string name="composer_error_store_draft_recipients">L\'enregistrement des destinataires du brouillon n\'a pas abouti.</string>
<string name="composer_error_loading_draft">Le contenu de ce brouillon n\'est pas disponible pour l\'instant. Sa modification remplacera tout contenu déjà existant.</string>
<string name="composer_add_attachments_content_description">Ajouter des pièces jointes</string>
@@ -88,7 +88,7 @@
<string name="set_message_password_supporting_text">4 à 21 caractères</string>
<string name="set_message_password_supporting_error_text">Le mot de passe doit contenir entre 4 et 21 caractères.</string>
<string name="set_message_password_supporting_text_repeat">Les mots de passe doivent être identiques.</string>
<string name="set_message_password_supporting_error_text_repeat">Les 2 mots de passe ne sont pas identiques.</string>
<string name="set_message_password_supporting_error_text_repeat">Les mots de passe ne correspondent pas.</string>
<string name="set_message_password_button_show">Afficher le mot de passe</string>
<string name="set_message_password_button_hide">Masquer le mot de passe</string>
<string name="set_message_password_button_apply">Appliquer le mot de passe</string>
@@ -96,7 +96,7 @@
<string name="set_message_password_button_remove_password">Retirer le mot de passe</string>
<string name="respondInline">Répondre dans le message</string>
<plurals name="composer_recipient_suggestion_contacts">
<item quantity="one">%d contact</item>
<item quantity="other">%d contacts</item>
<item quantity="one">%d membre</item>
<item quantity="other">%d membres</item>
</plurals>
</resources>
@@ -31,10 +31,10 @@
<string name="change_sender_button_content_description">Change sender</string>
<string name="composer_change_sender_paid_feature">Upgrade to a paid plan to change the sender address</string>
<string name="composer_error_change_sender_failed_getting_subscription">Cannot change sender as getting user\'s subscription failed. Try again.</string>
<string name="composer_error_store_draft_sender_address">Storing this draft\'s sender address failed</string>
<string name="composer_error_store_draft_body">Storing this draft\'s body failed</string>
<string name="composer_error_duplicate_recipient">Removed duplicate recipient: %1$s</string>
<string name="composer_error_store_draft_subject">Storing this draft\'s subject failed</string>
<string name="composer_error_store_draft_sender_address">Sender address wasn\'t saved</string>
<string name="composer_error_store_draft_body">Draft email body wasn\'t saved</string>
<string name="composer_error_duplicate_recipient">Duplicated recipient(s) removed</string>
<string name="composer_error_store_draft_subject">Subject line wasn\'t saved</string>
<string name="composer_error_store_draft_recipients">Storing this draft\'s recipients failed</string>
<string name="composer_error_loading_draft">The content of this draft is not available at this time. Editing will override any pre-existing content.</string>
<string name="composer_add_attachments_content_description">Dodaj privitke</string>
@@ -76,7 +76,7 @@
<!-- Message sending error dialog -->
<string name="message_sending_error_dialog_header">Sending error</string>
<string name="message_sending_error_dialog_button_dismiss">Zatvori</string>
<string name="message_sending_error_dialog_text_error">Your email cannot be sent to the email entered due to the following reason:</string>
<string name="message_sending_error_dialog_text_error">Your email cannot be sent to the address entered due to the following reason:</string>
<string name="message_sending_error_dialog_text_reason_no_trusted_keys">There are no Trusted Keys: %s</string>
<string name="message_sending_error_dialog_text_reason_address_disabled">Address does not exist: %s</string>
<string name="set_message_password_title">Encrypt message</string>
@@ -88,7 +88,7 @@
<string name="set_message_password_supporting_text">4 do 21 znakova dugo</string>
<string name="set_message_password_supporting_error_text">The password must be between 4 and 21 characters</string>
<string name="set_message_password_supporting_text_repeat">Lozinke se moraju poklapati</string>
<string name="set_message_password_supporting_error_text_repeat">Dvije lozinke se ne podudaraju</string>
<string name="set_message_password_supporting_error_text_repeat">Passwords do not match</string>
<string name="set_message_password_button_show">Pokaži lozinku</string>
<string name="set_message_password_button_hide">Sakrij lozinku</string>
<string name="set_message_password_button_apply">Primjeni lozinku</string>
@@ -96,8 +96,8 @@
<string name="set_message_password_button_remove_password">Ukloni lozinku</string>
<string name="respondInline">Odgovori u ravnini s ostalim tekstom</string>
<plurals name="composer_recipient_suggestion_contacts">
<item quantity="one">%d kontakt</item>
<item quantity="few">%d contacts</item>
<item quantity="other">%d contacts</item>
<item quantity="one">%d member</item>
<item quantity="few">%d members</item>
<item quantity="other">%d members</item>
</plurals>
</resources>
@@ -31,10 +31,10 @@
<string name="change_sender_button_content_description">Feladó módosítása</string>
<string name="composer_change_sender_paid_feature">Váltson előfizetéses csomagra, a feladó címének megváltoztatásához</string>
<string name="composer_error_change_sender_failed_getting_subscription">A feladó nem változtatható meg, mert az előfizetés betöltése nem sikerült. Próbálja újra.</string>
<string name="composer_error_store_draft_sender_address">A vázlat feladójának tárolása nem sikerült</string>
<string name="composer_error_store_draft_body">A vázlat szövegének tárolása nem sikerült</string>
<string name="composer_error_duplicate_recipient">Kettőzött címzett eltávolítva: %1$s</string>
<string name="composer_error_store_draft_subject">A vázlat tárgyának tárolása nem sikerült</string>
<string name="composer_error_store_draft_sender_address">Sender address wasn\'t saved</string>
<string name="composer_error_store_draft_body">Draft email body wasn\'t saved</string>
<string name="composer_error_duplicate_recipient">Duplicated recipient(s) removed</string>
<string name="composer_error_store_draft_subject">Subject line wasn\'t saved</string>
<string name="composer_error_store_draft_recipients">A vázlat címzetteinek tárolása nem sikerült</string>
<string name="composer_error_loading_draft">Ennek a piszkozatnak a tartalma jelenleg nem elérhető. Szerkesztése felülírja az esetlegesen már korábban hozzáadott tartalmat.</string>
<string name="composer_add_attachments_content_description">Melléklet hozzáadása</string>
@@ -76,7 +76,7 @@
<!-- Message sending error dialog -->
<string name="message_sending_error_dialog_header">Hiba a küldés közben</string>
<string name="message_sending_error_dialog_button_dismiss">Bezárás</string>
<string name="message_sending_error_dialog_text_error">A levél nem küldhető el a megadott e-mail címre a következő ok miatt:</string>
<string name="message_sending_error_dialog_text_error">Your email cannot be sent to the address entered due to the following reason:</string>
<string name="message_sending_error_dialog_text_reason_no_trusted_keys">Nincs megbízható kulcs: %s</string>
<string name="message_sending_error_dialog_text_reason_address_disabled">Cím nem létezik: %s</string>
<string name="set_message_password_title">Üzenet titkosítása</string>
@@ -88,7 +88,7 @@
<string name="set_message_password_supporting_text">4-től 21 karakter hosszúságig</string>
<string name="set_message_password_supporting_error_text">A jelszónak legalább 4, és legfeljebb 21 karakter hosszúnak kell lennie</string>
<string name="set_message_password_supporting_text_repeat">A jelszavaknak egyezniük kell</string>
<string name="set_message_password_supporting_error_text_repeat">A két jelszó nem egyezik</string>
<string name="set_message_password_supporting_error_text_repeat">Passwords do not match</string>
<string name="set_message_password_button_show">Jelszó megjelenítése</string>
<string name="set_message_password_button_hide">Jelszó elrejtése</string>
<string name="set_message_password_button_apply">Jelszó alkalmazása</string>
@@ -96,7 +96,7 @@
<string name="set_message_password_button_remove_password">Jelszó eltávolítása</string>
<string name="respondInline">Válaszolás az eredeti levélben</string>
<plurals name="composer_recipient_suggestion_contacts">
<item quantity="one">%d contact</item>
<item quantity="other">%d contacts</item>
<item quantity="one">%d member</item>
<item quantity="other">%d members</item>
</plurals>
</resources>
@@ -31,10 +31,10 @@
<string name="change_sender_button_content_description">Ubah pengirim</string>
<string name="composer_change_sender_paid_feature">Tingkatkan ke paket berbayar untuk mengubah alamat pengirim</string>
<string name="composer_error_change_sender_failed_getting_subscription">Pengirim tidak dapat diubah karena status langganan pengguna gagal dimuat. Silakan coba lagi.</string>
<string name="composer_error_store_draft_sender_address">Alamat pengirim draf gagal disimpan</string>
<string name="composer_error_store_draft_body">Isi draf gagal disimpan</string>
<string name="composer_error_duplicate_recipient">Penerima duplikat dihapus: %1$s</string>
<string name="composer_error_store_draft_subject">Subjek draf gagal disimpan</string>
<string name="composer_error_store_draft_sender_address">Sender address wasn\'t saved</string>
<string name="composer_error_store_draft_body">Draft email body wasn\'t saved</string>
<string name="composer_error_duplicate_recipient">Penerima duplikat dihapus</string>
<string name="composer_error_store_draft_subject">Subject line wasn\'t saved</string>
<string name="composer_error_store_draft_recipients">Penerima draf gagal disimpan</string>
<string name="composer_error_loading_draft">Konten draf ini sedang tidak tersedia. Dengan menyunting pesan, isi draf yang telah ada sebelumnya akan tertimpa.</string>
<string name="composer_add_attachments_content_description">Tambah lampiran</string>
@@ -76,7 +76,7 @@
<!-- Message sending error dialog -->
<string name="message_sending_error_dialog_header">Pesan gagal dikirim</string>
<string name="message_sending_error_dialog_button_dismiss">Tutup</string>
<string name="message_sending_error_dialog_text_error">Email Anda tidak dapat dikirim ke alamat email yang dituju karena alasan berikut:</string>
<string name="message_sending_error_dialog_text_error">Your email cannot be sent to the address entered due to the following reason:</string>
<string name="message_sending_error_dialog_text_reason_no_trusted_keys">Kunci Terpercaya tidak ada: %s</string>
<string name="message_sending_error_dialog_text_reason_address_disabled">Alamat tidak ada: %s</string>
<string name="set_message_password_title">Enkripsi pesan</string>
@@ -88,7 +88,7 @@
<string name="set_message_password_supporting_text">antara 4 hingga 21 karakter</string>
<string name="set_message_password_supporting_error_text">Kata sandi harus mengandung antara 4 dan 21 karakter</string>
<string name="set_message_password_supporting_text_repeat">Kata sandi harus sesuai</string>
<string name="set_message_password_supporting_error_text_repeat">Kedua kata sandi tidak sesuai</string>
<string name="set_message_password_supporting_error_text_repeat">Passwords do not match</string>
<string name="set_message_password_button_show">Tampilkan kata sandi</string>
<string name="set_message_password_button_hide">Sembunyikan kata sandi</string>
<string name="set_message_password_button_apply">Terapkan kata sandi</string>
@@ -96,6 +96,6 @@
<string name="set_message_password_button_remove_password">Hapus kata sandi</string>
<string name="respondInline">Tanggapi secara in-line</string>
<plurals name="composer_recipient_suggestion_contacts">
<item quantity="other">%d kontak</item>
<item quantity="other">%d anggota</item>
</plurals>
</resources>

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