(relaxUnitFun = true) {
+ every { state } returns appState
+ }
+
+ private lateinit var forceUpdateHandler: ForceUpdateHandler
+
+ @Before
+ fun setUp() {
+ forceUpdateHandler = ForceUpdateHandler(context, appLifecycleObserver)
+ }
+
+ @Test
+ fun whenForegroundOnForceUpdate() {
+ // GIVEN
+ appState.tryEmit(AppLifecycleProvider.State.Foreground)
+ // WHEN
+ forceUpdateHandler.onForceUpdate("Update")
+ // THEN
+ verify(atLeast = 1) { context.startActivity(any()) }
+ }
+
+ @Test
+ fun whenBackgroundOnForceUpdate() {
+ // GIVEN
+ appState.tryEmit(AppLifecycleProvider.State.Background)
+ // WHEN
+ forceUpdateHandler.onForceUpdate("Update")
+ // THEN
+ verify(exactly = 0) { context.startActivity(any()) }
+ }
+}
diff --git a/app/src/androidTest/kotlin/ch/protonmail/android/message/SplitMessageBodyQuoteTest.kt b/app/src/androidTest/kotlin/ch/protonmail/android/message/SplitMessageBodyQuoteTest.kt
new file mode 100644
index 0000000000..fd52fe712f
--- /dev/null
+++ b/app/src/androidTest/kotlin/ch/protonmail/android/message/SplitMessageBodyQuoteTest.kt
@@ -0,0 +1,108 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.message
+
+import ch.protonmail.android.mailcomposer.domain.model.DraftBody
+import ch.protonmail.android.mailcomposer.domain.usecase.SplitMessageBodyHtmlQuote
+import ch.protonmail.android.mailmessage.domain.model.MimeType
+import ch.protonmail.android.test.annotations.suite.SmokeTest
+import ch.protonmail.android.testdata.message.DecryptedMessageBodyTestData
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@SmokeTest
+class SplitMessageBodyQuoteTest {
+
+ private val splitMessageBodyHtmlQuote = SplitMessageBodyHtmlQuote()
+
+ @Test
+ fun returnsGivenDecryptedBodyAndNoQuoteWhenMimeTypeIsPlainText() {
+ // Given
+ val decryptedBody = DecryptedMessageBodyTestData.PlainTextDecryptedBody
+
+ // When
+ val actual = splitMessageBodyHtmlQuote(decryptedBody)
+
+ // Then
+ assertEquals(Pair(DraftBody(decryptedBody.value), null), actual)
+ }
+
+ @Test
+ fun returnsDecryptedBodyTextExtractedFromHtmlAndNoQuoteWhenTheInputBodyHasNoQuoteAnchors() {
+ // Given
+ val decryptedBody = DecryptedMessageBodyTestData.buildDecryptedMessageBody(
+ value = HtmlBodyWithNoQuoteAnchors,
+ mimeType = MimeType.Html
+ )
+
+ // When
+ val actual = splitMessageBodyHtmlQuote(decryptedBody)
+
+ // Then
+ val expected = DraftBody("$BodyTypedContentRaw \n$BodyMoreTypedContentRaw \n")
+ assertEquals(Pair(expected, null), actual)
+ }
+
+ @Test
+ fun returnsNonHtmlTextExtractedFromHtmlAndQuoteWhenTheInputBodyHasOneOfTheQuoteAnchors() {
+ // Given
+ val decryptedBody = DecryptedMessageBodyTestData.buildDecryptedMessageBody(
+ value = HtmlBodyWithProtonQuoteAnchor,
+ mimeType = MimeType.Html
+ )
+
+ // When
+ val actual = splitMessageBodyHtmlQuote(decryptedBody)
+ val actualQuote = actual.second!!
+
+ // Then
+ assertEquals(DraftBody(BodyTypedContentRaw), actual.first)
+ assertTrue(actualQuote.value.contains(ProtonQuoteAnchor))
+ assertTrue(actualQuote.value.contains(HtmlQuotedContentRaw))
+ }
+
+ companion object {
+ private const val BodyTypedContentRaw = "Typed in content"
+ private const val BodyMoreTypedContentRaw = "this is additional typed content"
+ private const val HtmlQuotedContentRaw = "Any quoted html content here"
+ private const val ProtonQuoteAnchor = ""
+ private const val ProtonQuoteClosingAnchor = "
"
+
+ private const val ProtonQuoteHtml = "$ProtonQuoteAnchor$HtmlQuotedContentRaw$ProtonQuoteClosingAnchor"
+
+ private const val HtmlBodyWithNoQuoteAnchors = """
+
+
+ $BodyTypedContentRaw
+ $BodyMoreTypedContentRaw
+
+
+ """
+
+ private const val HtmlBodyWithProtonQuoteAnchor = """
+
+
+ $BodyTypedContentRaw
+ $ProtonQuoteHtml
+
+
+ """
+ }
+}
diff --git a/app/src/dev/AndroidManifest.xml b/app/src/dev/AndroidManifest.xml
new file mode 100644
index 0000000000..d562e80937
--- /dev/null
+++ b/app/src/dev/AndroidManifest.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/dev/ic_launcher-playstore.png b/app/src/dev/ic_launcher-playstore.png
new file mode 100644
index 0000000000..f5d252178a
Binary files /dev/null and b/app/src/dev/ic_launcher-playstore.png differ
diff --git a/app/src/dev/kotlin/ch/protonmail/android/di/ServerProofModule.kt b/app/src/dev/kotlin/ch/protonmail/android/di/ServerProofModule.kt
new file mode 100644
index 0000000000..3abb5c1ce2
--- /dev/null
+++ b/app/src/dev/kotlin/ch/protonmail/android/di/ServerProofModule.kt
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import me.proton.core.auth.domain.usecase.ValidateServerProof
+
+/**
+ * This module is an explicit provider for [ValidateServerProof] that is needed to perform the required
+ * overrides when running UI Tests.
+ *
+ * There's no need to have this definition in the production code, as the dependency is still automatically provided.
+ */
+@Module
+@InstallIn(SingletonComponent::class)
+object ServerProofModule {
+
+ @Provides
+ fun provideServerProofValidation(): ValidateServerProof = ValidateServerProof()
+}
diff --git a/app/src/dev/res/drawable-v24/ic_launcher_foreground.xml b/app/src/dev/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000000..abc4ebc37d
--- /dev/null
+++ b/app/src/dev/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000000..083ceff9ab
--- /dev/null
+++ b/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/dev/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/dev/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000000..083ceff9ab
--- /dev/null
+++ b/app/src/dev/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/dev/res/mipmap-hdpi/ic_launcher.png b/app/src/dev/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..29edd9c0f4
Binary files /dev/null and b/app/src/dev/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/dev/res/mipmap-hdpi/ic_launcher_round.png b/app/src/dev/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..a921b8b78b
Binary files /dev/null and b/app/src/dev/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/dev/res/mipmap-mdpi/ic_launcher.png b/app/src/dev/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..175dbdc2ad
Binary files /dev/null and b/app/src/dev/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/dev/res/mipmap-mdpi/ic_launcher_round.png b/app/src/dev/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..bab228e599
Binary files /dev/null and b/app/src/dev/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/dev/res/mipmap-xhdpi/ic_launcher.png b/app/src/dev/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..8f84b52977
Binary files /dev/null and b/app/src/dev/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/dev/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/dev/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..f947ffc439
Binary files /dev/null and b/app/src/dev/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png b/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..e0f214062f
Binary files /dev/null and b/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/dev/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/dev/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..33643f2454
Binary files /dev/null and b/app/src/dev/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..61d05e3a94
Binary files /dev/null and b/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..ae270d96b0
Binary files /dev/null and b/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/dev/res/xml/pm_network_security_config.xml b/app/src/dev/res/xml/pm_network_security_config.xml
new file mode 100644
index 0000000000..9e269d35cf
--- /dev/null
+++ b/app/src/dev/res/xml/pm_network_security_config.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8dcee12aa5..1b8f8b6f28 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,23 +1,310 @@
-
+
+
+ xmlns:tools="http://schemas.android.com/tools">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:supportsRtl="false"
+ android:taskAffinity=""
+
+ android:theme="@style/ProtonTheme.Mail"
+ tools:replace="android:theme,android:supportsRtl">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:exported="true"
+ android:launchMode="standard"
+ android:theme="@style/ProtonTheme.Splash.Mail"
+ android:windowSoftInputMode="adjustResize">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000000..9af7a3e84a
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/ch/protonmail/android/MainActivity.kt b/app/src/main/java/ch/protonmail/android/MainActivity.kt
deleted file mode 100644
index 5cc421fb4e..0000000000
--- a/app/src/main/java/ch/protonmail/android/MainActivity.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package ch.protonmail.android
-
-import androidx.appcompat.app.AppCompatActivity
-import android.os.Bundle
-
-class MainActivity : AppCompatActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_main)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/ch/protonmail/android/App.kt b/app/src/main/kotlin/ch/protonmail/android/App.kt
new file mode 100644
index 0000000000..41b7122de6
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/App.kt
@@ -0,0 +1,69 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android
+
+import android.app.Application
+import androidx.lifecycle.ProcessLifecycleOwner
+import ch.protonmail.android.callbacks.AutoLockLifecycleCallbacks
+import ch.protonmail.android.callbacks.SecureActivityLifecycleCallbacks
+import ch.protonmail.android.initializer.MainInitializer
+import ch.protonmail.android.logging.LogsFileHandlerLifecycleObserver
+import ch.protonmail.android.mailbugreport.domain.LogsExportFeatureSetting
+import ch.protonmail.android.mailbugreport.domain.annotations.LogsExportFeatureSettingValue
+import ch.protonmail.android.mailcommon.domain.benchmark.BenchmarkTracer
+import dagger.hilt.android.HiltAndroidApp
+import javax.inject.Inject
+import javax.inject.Provider
+
+@HiltAndroidApp
+internal class App : Application() {
+
+ @Inject
+ lateinit var secureActivityLifecycleCallbacks: SecureActivityLifecycleCallbacks
+
+ @Inject
+ lateinit var lockScreenLifecycleCallbacks: AutoLockLifecycleCallbacks
+
+ @Inject
+ lateinit var benchmarkTracer: BenchmarkTracer
+
+ @Inject
+ @LogsExportFeatureSettingValue
+ lateinit var logsExportFeatureSetting: Provider
+
+ override fun onCreate() {
+ super.onCreate()
+
+ benchmarkTracer.begin("proton-app-init")
+
+ MainInitializer.init(this)
+ registerActivityLifecycleCallbacks(secureActivityLifecycleCallbacks)
+ registerActivityLifecycleCallbacks(lockScreenLifecycleCallbacks)
+
+ addLogsFileHandlerObserver()
+
+ benchmarkTracer.end()
+ }
+
+ private fun addLogsFileHandlerObserver() {
+ if (logsExportFeatureSetting.get().isEnabled) {
+ ProcessLifecycleOwner.get().lifecycle.addObserver(LogsFileHandlerLifecycleObserver(this))
+ }
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/LockScreenActivity.kt b/app/src/main/kotlin/ch/protonmail/android/LockScreenActivity.kt
new file mode 100644
index 0000000000..aa95c030cd
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/LockScreenActivity.kt
@@ -0,0 +1,126 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android
+
+import java.util.concurrent.Executors
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.biometric.BiometricPrompt
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.Lifecycle
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.rememberNavController
+import ch.protonmail.android.mailsettings.domain.model.autolock.AutoLockInsertionMode
+import ch.protonmail.android.mailsettings.domain.model.autolock.biometric.BiometricPromptCallback
+import ch.protonmail.android.navigation.listener.withDestinationChangedObservableEffect
+import ch.protonmail.android.navigation.model.Destination
+import ch.protonmail.android.navigation.route.addAutoLockPinScreen
+import dagger.hilt.android.AndroidEntryPoint
+import io.sentry.compose.withSentryObservableEffect
+import me.proton.core.compose.theme.ProtonTheme
+
+@AndroidEntryPoint
+internal class LockScreenActivity : AppCompatActivity() {
+
+ private var biometricPrompt: BiometricPrompt? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ ProtonTheme {
+ val navController = rememberNavController()
+ .withSentryObservableEffect()
+ .withDestinationChangedObservableEffect()
+
+ NavHost(
+ modifier = Modifier.fillMaxSize(),
+ navController = navController,
+ startDestination = Destination.Screen.AutoLockPinScreen.route
+ ) {
+ addAutoLockPinScreen(
+ onShowSuccessSnackbar = {},
+ onBack = { this@LockScreenActivity.finish() },
+ activityActions = Actions(
+ finishActivity = { finish() },
+ showBiometricPrompt = { callback ->
+ showBiometricPrompt(callback)
+ }
+ )
+ )
+ }
+
+ navController.navigate(
+ Destination.Screen.AutoLockPinScreen(AutoLockInsertionMode.VerifyPin)
+ ) {
+ popUpTo(navController.graph.id) { inclusive = true }
+ }
+ }
+ }
+ }
+
+ private fun showBiometricPrompt(callback: BiometricPromptCallback) {
+ val executor = Executors.newSingleThreadExecutor()
+ biometricPrompt = BiometricPrompt(
+ this, executor,
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ super.onAuthenticationError(errorCode, errString)
+ callback.onAuthenticationError()
+
+ if (!this@LockScreenActivity.isFinishing &&
+ lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)
+ ) {
+ biometricPrompt?.cancelAuthentication()
+ biometricPrompt = null
+ }
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ super.onAuthenticationSucceeded(result)
+
+ callback.onAuthenticationSucceeded()
+ }
+ }
+ )
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setTitle(getString(R.string.app_locked))
+ .setDescription(getString(R.string.log_in_using_biometric_credential))
+ .setNegativeButtonText(getString(R.string.use_pin_instead))
+ .build()
+ biometricPrompt?.authenticate(promptInfo)
+ }
+
+
+ data class Actions(
+ val finishActivity: () -> Unit,
+ val showBiometricPrompt: (BiometricPromptCallback) -> Unit
+ ) {
+
+ companion object {
+
+ val Empty = Actions(
+ finishActivity = {},
+ showBiometricPrompt = {}
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/MainActivity.kt b/app/src/main/kotlin/ch/protonmail/android/MainActivity.kt
new file mode 100644
index 0000000000..06aa8e2964
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/MainActivity.kt
@@ -0,0 +1,170 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android
+
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.lifecycle.lifecycleScope
+import ch.protonmail.android.feature.postsubscription.ObservePostSubscription
+import ch.protonmail.android.mailcommon.domain.system.DeviceCapabilities
+import ch.protonmail.android.mailcommon.presentation.system.LocalDeviceCapabilitiesProvider
+import ch.protonmail.android.maildetail.domain.model.OpenAttachmentIntentValues
+import ch.protonmail.android.maildetail.domain.model.OpenProtonCalendarIntentValues
+import ch.protonmail.android.maildetail.presentation.util.ProtonCalendarUtil
+import ch.protonmail.android.navigation.Launcher
+import ch.protonmail.android.navigation.LauncherViewModel
+import ch.protonmail.android.navigation.model.LauncherState
+import ch.protonmail.android.navigation.share.ShareIntentObserver
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import me.proton.core.compose.theme.ProtonTheme
+import me.proton.core.notification.presentation.deeplink.DeeplinkManager
+import me.proton.core.notification.presentation.deeplink.onActivityCreate
+import timber.log.Timber
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class MainActivity : AppCompatActivity() {
+
+ @Inject
+ lateinit var deeplinkManager: DeeplinkManager
+
+ @Inject
+ lateinit var deviceCapabilities: DeviceCapabilities
+
+ @Inject
+ lateinit var shareIntentObserver: ShareIntentObserver
+
+ @Inject
+ lateinit var observePostSubscription: ObservePostSubscription
+
+ private val launcherViewModel: LauncherViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen().setKeepOnScreenCondition {
+ launcherViewModel.state.value == LauncherState.Processing
+ }
+ super.onCreate(savedInstanceState)
+
+ deeplinkManager.onActivityCreate(this, savedInstanceState)
+
+ // Register activities for result.
+ launcherViewModel.register(this)
+
+ lifecycleScope.launch {
+ observePostSubscription.start(this@MainActivity)
+ }
+
+ shareIntentObserver.onNewIntent(intent)
+
+ disableRecentAppsScreenshotPreview()
+
+ setContent {
+ ProtonTheme {
+ CompositionLocalProvider(
+ LocalDeviceCapabilitiesProvider provides deviceCapabilities.getCapabilities()
+ ) {
+ Launcher(
+ Actions(
+ openInActivityInNewTask = { openInActivityInNewTask(it) },
+ openIntentChooser = { openIntentChooser(it) },
+ openProtonCalendarIntentValues = { handleProtonCalendarRequest(it) },
+ openSecurityKeys = { launcherViewModel.submit(LauncherViewModel.Action.OpenSecurityKeys) },
+ finishActivity = { finishAfterTransition() }
+ ),
+ launcherViewModel
+ )
+ }
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ launcherViewModel.unregister()
+ super.onDestroy()
+ }
+
+ private fun disableRecentAppsScreenshotPreview() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ setRecentsScreenshotEnabled(BuildConfig.DEBUG)
+ }
+ }
+
+ private fun openInActivityInNewTask(uri: Uri) {
+ val intent = Intent(Intent.ACTION_VIEW, uri)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ try {
+ startActivity(intent)
+ } catch (e: ActivityNotFoundException) {
+ Timber.d(e, "Failed to open a browser app")
+
+ Toast.makeText(
+ this,
+ getString(R.string.intent_failure_no_app_found_to_handle_this_action),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+
+ private fun openIntentChooser(intentValues: OpenAttachmentIntentValues) {
+ val intent = Intent(Intent.ACTION_VIEW)
+ .setDataAndType(intentValues.uri, intentValues.mimeType)
+ .putExtra(Intent.EXTRA_STREAM, intentValues.uri)
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ try {
+ startActivity(intent)
+ } catch (e: ActivityNotFoundException) {
+ Timber.d(e, "Failed to open intent for file type")
+ startActivity(Intent.createChooser(intent, null))
+ }
+ }
+
+ private fun handleProtonCalendarRequest(values: OpenProtonCalendarIntentValues) {
+ val intent = when (values) {
+ is OpenProtonCalendarIntentValues.OpenIcsInProtonCalendar ->
+ ProtonCalendarUtil.getIntentToOpenIcsInProtonCalendar(
+ values.uriToIcsAttachment,
+ values.sender,
+ values.recipient
+ )
+
+ is OpenProtonCalendarIntentValues.OpenProtonCalendarOnPlayStore ->
+ ProtonCalendarUtil.getIntentToProtonCalendarOnPlayStore()
+ }
+ startActivity(intent)
+ }
+
+ data class Actions(
+ val openInActivityInNewTask: (uri: Uri) -> Unit,
+ val openIntentChooser: (values: OpenAttachmentIntentValues) -> Unit,
+ val openProtonCalendarIntentValues: (values: OpenProtonCalendarIntentValues) -> Unit,
+ val openSecurityKeys: () -> Unit,
+ val finishActivity: () -> Unit
+ )
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/PostSubscriptionActivity.kt b/app/src/main/kotlin/ch/protonmail/android/PostSubscriptionActivity.kt
new file mode 100644
index 0000000000..eea9ecb3a5
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/PostSubscriptionActivity.kt
@@ -0,0 +1,63 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.rememberNavController
+import ch.protonmail.android.navigation.listener.withDestinationChangedObservableEffect
+import ch.protonmail.android.navigation.model.Destination
+import ch.protonmail.android.navigation.route.addPostSubscription
+import dagger.hilt.android.AndroidEntryPoint
+import io.sentry.compose.withSentryObservableEffect
+import me.proton.core.compose.theme.ProtonTheme
+
+@AndroidEntryPoint
+class PostSubscriptionActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ ProtonTheme {
+ val navController = rememberNavController()
+ .withSentryObservableEffect()
+ .withDestinationChangedObservableEffect()
+
+ NavHost(
+ modifier = Modifier.fillMaxSize(),
+ navController = navController,
+ startDestination = Destination.Screen.PostSubscription.route
+ ) {
+ addPostSubscription(
+ onClose = { this@PostSubscriptionActivity.finish() }
+ )
+ }
+
+ navController.navigate(
+ Destination.Screen.PostSubscription.route
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/callbacks/AutoLockLifecycleCallbacks.kt b/app/src/main/kotlin/ch/protonmail/android/callbacks/AutoLockLifecycleCallbacks.kt
new file mode 100644
index 0000000000..dcc09810d3
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/callbacks/AutoLockLifecycleCallbacks.kt
@@ -0,0 +1,63 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.callbacks
+
+import android.app.Activity
+import android.app.Application
+import android.content.Intent
+import android.os.Bundle
+import ch.protonmail.android.LockScreenActivity
+import ch.protonmail.android.mailcommon.domain.coroutines.AppScope
+import ch.protonmail.android.mailsettings.domain.usecase.autolock.ShouldPresentPinInsertionScreen
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+internal class AutoLockLifecycleCallbacks @Inject constructor(
+ private val shouldPresentPinInsertionScreen: ShouldPresentPinInsertionScreen,
+ @AppScope private val coroutineScope: CoroutineScope
+) : Application.ActivityLifecycleCallbacks {
+
+ private var job: Job? = null
+
+ override fun onActivityResumed(activity: Activity) {
+ if (activity is LockScreenActivity) return
+
+ job = coroutineScope.launch {
+ shouldPresentPinInsertionScreen().collectLatest { forcePinInsertion ->
+ if (!forcePinInsertion) return@collectLatest
+ val intent = Intent(activity, LockScreenActivity::class.java)
+ activity.startActivity(intent)
+ }
+ }
+ }
+
+ override fun onActivityPaused(p0: Activity) {
+ job?.cancel()
+ job = null
+ }
+
+ override fun onActivityCreated(p0: Activity, p1: Bundle?) = Unit
+ override fun onActivityStarted(p0: Activity) = Unit
+ override fun onActivityStopped(p0: Activity) = Unit
+ override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) = Unit
+ override fun onActivityDestroyed(p0: Activity) = Unit
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/callbacks/SecureActivityLifecycleCallbacks.kt b/app/src/main/kotlin/ch/protonmail/android/callbacks/SecureActivityLifecycleCallbacks.kt
new file mode 100644
index 0000000000..0bb2191956
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/callbacks/SecureActivityLifecycleCallbacks.kt
@@ -0,0 +1,98 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.callbacks
+
+import android.app.Activity
+import android.app.Application.ActivityLifecycleCallbacks
+import android.os.Bundle
+import android.view.WindowManager
+import arrow.core.getOrElse
+import ch.protonmail.android.LockScreenActivity
+import ch.protonmail.android.mailcommon.domain.coroutines.AppScope
+import ch.protonmail.android.mailsettings.domain.usecase.privacy.ObservePreventScreenshotsSetting
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import me.proton.core.auth.presentation.ui.AuthActivity
+import me.proton.core.usersettings.presentation.ui.PasswordManagementActivity
+import timber.log.Timber
+import javax.inject.Inject
+
+internal class SecureActivityLifecycleCallbacks @Inject constructor(
+ private val observePreventScreenshotsSetting: ObservePreventScreenshotsSetting,
+ @AppScope private val coroutineScope: CoroutineScope
+) : ActivityLifecycleCallbacks {
+
+ private var setSecureJob: Job? = null
+
+ override fun onActivityResumed(activity: Activity) {
+ // Regardless of the user-defined setting, a subset of activities will always be secure.
+ if (activity.isSecureActivity()) {
+ setSecureFlags(activity)
+ return
+ }
+
+ setSecureJob = coroutineScope.launch {
+ observePreventScreenshotsSetting().collectLatest {
+ val preventTakingScreenshotsPreference = it.getOrElse {
+ Timber.e("Unable to get 'Prevent taking screenshots' setting.")
+ return@collectLatest
+ }
+
+ withContext(Dispatchers.Main) {
+ if (preventTakingScreenshotsPreference.isEnabled) {
+ setSecureFlags(activity)
+ } else {
+ clearSecureFlags(activity)
+ }
+ }
+ }
+ }
+ }
+
+ override fun onActivityPaused(p0: Activity) {
+ setSecureJob?.cancel()
+ setSecureJob = null
+ }
+
+ override fun onActivityCreated(p0: Activity, p1: Bundle?) = Unit
+ override fun onActivityStarted(p0: Activity) = Unit
+ override fun onActivityStopped(p0: Activity) = Unit
+ override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) = Unit
+ override fun onActivityDestroyed(p0: Activity) = Unit
+
+ private fun setSecureFlags(activity: Activity) {
+ activity.window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
+ }
+
+ private fun clearSecureFlags(activity: Activity) {
+ activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+
+ private fun Activity.isSecureActivity(): Boolean = when (this) {
+ is AuthActivity<*>,
+ is PasswordManagementActivity,
+ is LockScreenActivity -> true
+
+ else -> false
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/db/AppDatabase.kt b/app/src/main/kotlin/ch/protonmail/android/db/AppDatabase.kt
new file mode 100644
index 0000000000..e4d3c9374c
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/db/AppDatabase.kt
@@ -0,0 +1,316 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.db
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.TypeConverters
+import ch.protonmail.android.composer.data.local.DraftStateDatabase
+import ch.protonmail.android.composer.data.local.converters.AttachmentStateConverters
+import ch.protonmail.android.composer.data.local.converters.DraftStateConverters
+import ch.protonmail.android.composer.data.local.entity.MessageExpirationTimeEntity
+import ch.protonmail.android.composer.data.local.entity.MessagePasswordEntity
+import ch.protonmail.android.mailconversation.data.local.ConversationDatabase
+import ch.protonmail.android.mailconversation.data.local.converters.ConversationConverters
+import ch.protonmail.android.mailconversation.data.local.converters.MapConverters
+import ch.protonmail.android.mailconversation.data.local.entity.ConversationEntity
+import ch.protonmail.android.mailconversation.data.local.entity.ConversationLabelEntity
+import ch.protonmail.android.mailconversation.data.local.entity.UnreadConversationsCountEntity
+import ch.protonmail.android.mailmessage.data.local.MessageConverters
+import ch.protonmail.android.mailmessage.data.local.MessageDatabase
+import ch.protonmail.android.mailmessage.data.local.SearchResultsDatabase
+import ch.protonmail.android.mailmessage.data.local.converters.AttachmentWorkerStatusConverters
+import ch.protonmail.android.mailmessage.data.local.converters.UriConverter
+import ch.protonmail.android.mailmessage.data.local.entity.AttachmentStateEntity
+import ch.protonmail.android.mailmessage.data.local.entity.DraftStateEntity
+import ch.protonmail.android.mailmessage.data.local.entity.MessageAttachmentEntity
+import ch.protonmail.android.mailmessage.data.local.entity.MessageAttachmentMetadataEntity
+import ch.protonmail.android.mailmessage.data.local.entity.MessageBodyEntity
+import ch.protonmail.android.mailmessage.data.local.entity.MessageEntity
+import ch.protonmail.android.mailmessage.data.local.entity.MessageLabelEntity
+import ch.protonmail.android.mailmessage.data.local.entity.SearchResultEntity
+import ch.protonmail.android.mailmessage.data.local.entity.UnreadMessagesCountEntity
+import ch.protonmail.android.mailpagination.data.local.PageIntervalDatabase
+import ch.protonmail.android.mailpagination.data.local.entity.PageIntervalEntity
+import me.proton.core.account.data.db.AccountConverters
+import me.proton.core.account.data.db.AccountDatabase
+import me.proton.core.account.data.entity.AccountEntity
+import me.proton.core.account.data.entity.AccountMetadataEntity
+import me.proton.core.account.data.entity.SessionDetailsEntity
+import me.proton.core.account.data.entity.SessionEntity
+import me.proton.core.auth.data.db.AuthConverters
+import me.proton.core.auth.data.db.AuthDatabase
+import me.proton.core.auth.data.entity.AuthDeviceEntity
+import me.proton.core.auth.data.entity.DeviceSecretEntity
+import me.proton.core.auth.data.entity.MemberDeviceEntity
+import me.proton.core.challenge.data.db.ChallengeConverters
+import me.proton.core.challenge.data.db.ChallengeDatabase
+import me.proton.core.challenge.data.entity.ChallengeFrameEntity
+import me.proton.core.contact.data.local.db.ContactConverters
+import me.proton.core.contact.data.local.db.ContactDatabase
+import me.proton.core.contact.data.local.db.entity.ContactCardEntity
+import me.proton.core.contact.data.local.db.entity.ContactEmailEntity
+import me.proton.core.contact.data.local.db.entity.ContactEmailLabelEntity
+import me.proton.core.contact.data.local.db.entity.ContactEntity
+import me.proton.core.crypto.android.keystore.CryptoConverters
+import me.proton.core.data.room.db.BaseDatabase
+import me.proton.core.data.room.db.CommonConverters
+import me.proton.core.eventmanager.data.db.EventManagerConverters
+import me.proton.core.eventmanager.data.db.EventMetadataDatabase
+import me.proton.core.eventmanager.data.entity.EventMetadataEntity
+import me.proton.core.featureflag.data.db.FeatureFlagDatabase
+import me.proton.core.featureflag.data.entity.FeatureFlagEntity
+import me.proton.core.humanverification.data.db.HumanVerificationConverters
+import me.proton.core.humanverification.data.db.HumanVerificationDatabase
+import me.proton.core.humanverification.data.entity.HumanVerificationEntity
+import me.proton.core.key.data.db.KeySaltDatabase
+import me.proton.core.key.data.db.PublicAddressDatabase
+import me.proton.core.key.data.entity.KeySaltEntity
+import me.proton.core.key.data.entity.PublicAddressEntity
+import me.proton.core.key.data.entity.PublicAddressInfoEntity
+import me.proton.core.key.data.entity.PublicAddressKeyDataEntity
+import me.proton.core.key.data.entity.PublicAddressKeyEntity
+import me.proton.core.keytransparency.data.local.KeyTransparencyDatabase
+import me.proton.core.keytransparency.data.local.entity.AddressChangeEntity
+import me.proton.core.keytransparency.data.local.entity.SelfAuditResultEntity
+import me.proton.core.label.data.local.LabelConverters
+import me.proton.core.label.data.local.LabelDatabase
+import me.proton.core.label.data.local.LabelEntity
+import me.proton.core.mailsettings.data.db.MailSettingsDatabase
+import me.proton.core.mailsettings.data.entity.MailSettingsEntity
+import me.proton.core.notification.data.local.db.NotificationConverters
+import me.proton.core.notification.data.local.db.NotificationDatabase
+import me.proton.core.notification.data.local.db.NotificationEntity
+import me.proton.core.observability.data.db.ObservabilityDatabase
+import me.proton.core.observability.data.entity.ObservabilityEventEntity
+import me.proton.core.payment.data.local.db.PaymentDatabase
+import me.proton.core.payment.data.local.entity.GooglePurchaseEntity
+import me.proton.core.payment.data.local.entity.PurchaseEntity
+import me.proton.core.push.data.local.db.PushConverters
+import me.proton.core.push.data.local.db.PushDatabase
+import me.proton.core.push.data.local.db.PushEntity
+import me.proton.core.telemetry.data.db.TelemetryDatabase
+import me.proton.core.telemetry.data.entity.TelemetryEventEntity
+import me.proton.core.user.data.db.AddressDatabase
+import me.proton.core.user.data.db.UserConverters
+import me.proton.core.user.data.db.UserDatabase
+import me.proton.core.user.data.entity.AddressEntity
+import me.proton.core.user.data.entity.AddressKeyEntity
+import me.proton.core.user.data.entity.UserEntity
+import me.proton.core.user.data.entity.UserKeyEntity
+import me.proton.core.userrecovery.data.db.DeviceRecoveryDatabase
+import me.proton.core.userrecovery.data.entity.RecoveryFileEntity
+import me.proton.core.usersettings.data.db.OrganizationDatabase
+import me.proton.core.usersettings.data.db.UserSettingsConverters
+import me.proton.core.usersettings.data.db.UserSettingsDatabase
+import me.proton.core.usersettings.data.entity.OrganizationEntity
+import me.proton.core.usersettings.data.entity.OrganizationKeysEntity
+import me.proton.core.usersettings.data.entity.UserSettingsEntity
+
+@Database(
+ entities = [
+ // account-data
+ AccountEntity::class,
+ AccountMetadataEntity::class,
+ SessionEntity::class,
+ SessionDetailsEntity::class,
+ // auth-data
+ AuthDeviceEntity::class,
+ DeviceSecretEntity::class,
+ MemberDeviceEntity::class,
+ // user-data
+ UserEntity::class,
+ UserKeyEntity::class,
+ AddressEntity::class,
+ AddressKeyEntity::class,
+ // user-recovery
+ RecoveryFileEntity::class,
+ // key-data
+ KeySaltEntity::class,
+ PublicAddressEntity::class,
+ PublicAddressKeyEntity::class,
+ PublicAddressInfoEntity::class,
+ PublicAddressKeyDataEntity::class,
+ // human-verification
+ HumanVerificationEntity::class,
+ // mail-settings
+ MailSettingsEntity::class,
+ // user-settings
+ UserSettingsEntity::class,
+ // organization
+ OrganizationEntity::class,
+ OrganizationKeysEntity::class,
+ // contact
+ ContactEntity::class,
+ ContactCardEntity::class,
+ ContactEmailEntity::class,
+ ContactEmailLabelEntity::class,
+ // event-manager
+ EventMetadataEntity::class,
+ // label
+ LabelEntity::class,
+ // feature-flag
+ FeatureFlagEntity::class,
+ // challenge
+ ChallengeFrameEntity::class,
+ // notification
+ NotificationEntity::class,
+ // push
+ PushEntity::class,
+ // mail-pagination
+ PageIntervalEntity::class,
+ // mail-message
+ MessageEntity::class,
+ MessageLabelEntity::class,
+ MessageBodyEntity::class,
+ MessageAttachmentEntity::class,
+ MessageAttachmentMetadataEntity::class,
+ // mail-conversation
+ ConversationEntity::class,
+ ConversationLabelEntity::class,
+ // in app purchase
+ GooglePurchaseEntity::class,
+ PurchaseEntity::class,
+ // observability
+ ObservabilityEventEntity::class,
+ // telemetry
+ TelemetryEventEntity::class,
+ // key transparency
+ AddressChangeEntity::class,
+ SelfAuditResultEntity::class,
+ // draft state
+ DraftStateEntity::class,
+ AttachmentStateEntity::class,
+ MessagePasswordEntity::class,
+ MessageExpirationTimeEntity::class,
+ // Unread counts
+ UnreadMessagesCountEntity::class,
+ UnreadConversationsCountEntity::class,
+ // Search results
+ SearchResultEntity::class
+ ],
+ version = AppDatabase.version,
+ exportSchema = true
+)
+@TypeConverters(
+ CommonConverters::class,
+ AccountConverters::class,
+ UserConverters::class,
+ CryptoConverters::class,
+ HumanVerificationConverters::class,
+ UserSettingsConverters::class,
+ ContactConverters::class,
+ EventManagerConverters::class,
+ LabelConverters::class,
+ ChallengeConverters::class,
+ NotificationConverters::class,
+ PushConverters::class,
+ MessageConverters::class,
+ ConversationConverters::class,
+ MapConverters::class,
+ AttachmentWorkerStatusConverters::class,
+ UriConverter::class,
+ DraftStateConverters::class,
+ AttachmentStateConverters::class,
+ AuthConverters::class
+)
+@Suppress("UnnecessaryAbstractClass")
+abstract class AppDatabase :
+ BaseDatabase(),
+ AccountDatabase,
+ UserDatabase,
+ AddressDatabase,
+ KeySaltDatabase,
+ HumanVerificationDatabase,
+ PublicAddressDatabase,
+ MailSettingsDatabase,
+ UserSettingsDatabase,
+ OrganizationDatabase,
+ ContactDatabase,
+ EventMetadataDatabase,
+ LabelDatabase,
+ FeatureFlagDatabase,
+ ChallengeDatabase,
+ PageIntervalDatabase,
+ MessageDatabase,
+ ConversationDatabase,
+ PaymentDatabase,
+ ObservabilityDatabase,
+ KeyTransparencyDatabase,
+ NotificationDatabase,
+ PushDatabase,
+ TelemetryDatabase,
+ DraftStateDatabase,
+ SearchResultsDatabase,
+ DeviceRecoveryDatabase,
+ AuthDatabase {
+
+ companion object {
+
+ const val name = "db-mail"
+ const val version = 41
+
+ internal val migrations = listOf(
+ AppDatabaseMigrations.MIGRATION_1_2,
+ AppDatabaseMigrations.MIGRATION_2_3,
+ AppDatabaseMigrations.MIGRATION_3_4,
+ AppDatabaseMigrations.MIGRATION_4_5,
+ AppDatabaseMigrations.MIGRATION_5_6,
+ AppDatabaseMigrations.MIGRATION_6_7,
+ AppDatabaseMigrations.MIGRATION_7_8,
+ AppDatabaseMigrations.MIGRATION_8_9,
+ AppDatabaseMigrations.MIGRATION_9_10,
+ AppDatabaseMigrations.MIGRATION_10_11,
+ AppDatabaseMigrations.MIGRATION_11_12,
+ AppDatabaseMigrations.MIGRATION_12_13,
+ AppDatabaseMigrations.MIGRATION_13_14,
+ AppDatabaseMigrations.MIGRATION_14_15,
+ AppDatabaseMigrations.MIGRATION_15_16,
+ AppDatabaseMigrations.MIGRATION_16_17,
+ AppDatabaseMigrations.MIGRATION_17_18,
+ AppDatabaseMigrations.MIGRATION_18_19,
+ AppDatabaseMigrations.MIGRATION_19_20,
+ AppDatabaseMigrations.MIGRATION_20_21,
+ AppDatabaseMigrations.MIGRATION_21_22,
+ AppDatabaseMigrations.MIGRATION_22_23,
+ AppDatabaseMigrations.MIGRATION_23_24,
+ AppDatabaseMigrations.MIGRATION_24_25,
+ AppDatabaseMigrations.MIGRATION_25_26,
+ AppDatabaseMigrations.MIGRATION_26_27,
+ AppDatabaseMigrations.MIGRATION_27_28,
+ AppDatabaseMigrations.MIGRATION_28_29,
+ AppDatabaseMigrations.MIGRATION_29_30,
+ AppDatabaseMigrations.MIGRATION_30_31,
+ AppDatabaseMigrations.MIGRATION_31_32,
+ AppDatabaseMigrations.MIGRATION_32_33,
+ AppDatabaseMigrations.MIGRATION_33_34,
+ AppDatabaseMigrations.MIGRATION_34_35,
+ AppDatabaseMigrations.MIGRATION_35_36,
+ AppDatabaseMigrations.MIGRATION_36_37,
+ AppDatabaseMigrations.MIGRATION_37_38,
+ AppDatabaseMigrations.MIGRATION_38_39,
+ AppDatabaseMigrations.MIGRATION_39_40,
+ AppDatabaseMigrations.MIGRATION_40_41
+ )
+
+ fun buildDatabase(context: Context): AppDatabase = databaseBuilder(context, name)
+ .apply { migrations.forEach { addMigrations(it) } }
+ .build()
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/db/AppDatabaseMigrations.kt b/app/src/main/kotlin/ch/protonmail/android/db/AppDatabaseMigrations.kt
new file mode 100644
index 0000000000..f1fcf96079
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/db/AppDatabaseMigrations.kt
@@ -0,0 +1,317 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.db
+
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+import ch.protonmail.android.composer.data.local.DraftStateDatabase
+import ch.protonmail.android.mailconversation.data.local.ConversationDatabase
+import ch.protonmail.android.mailmessage.data.local.MessageDatabase
+import ch.protonmail.android.mailmessage.data.local.SearchResultsDatabase
+import me.proton.core.account.data.db.AccountDatabase
+import me.proton.core.auth.data.db.AuthDatabase
+import me.proton.core.contact.data.local.db.ContactDatabase
+import me.proton.core.eventmanager.data.db.EventMetadataDatabase
+import me.proton.core.key.data.db.PublicAddressDatabase
+import me.proton.core.keytransparency.data.local.KeyTransparencyDatabase
+import me.proton.core.mailsettings.data.db.MailSettingsDatabase
+import me.proton.core.notification.data.local.db.NotificationDatabase
+import me.proton.core.payment.data.local.db.PaymentDatabase
+import me.proton.core.push.data.local.db.PushDatabase
+import me.proton.core.telemetry.data.db.TelemetryDatabase
+import me.proton.core.user.data.db.AddressDatabase
+import me.proton.core.user.data.db.UserDatabase
+import me.proton.core.user.data.db.UserKeyDatabase
+import me.proton.core.userrecovery.data.db.DeviceRecoveryDatabase
+import me.proton.core.usersettings.data.db.OrganizationDatabase
+import me.proton.core.usersettings.data.db.UserSettingsDatabase
+
+object AppDatabaseMigrations {
+
+ val MIGRATION_1_2 = object : Migration(1, 2) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ MessageDatabase.MIGRATION_0.migrate(db)
+ }
+ }
+
+ val MIGRATION_2_3 = object : Migration(2, 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ OrganizationDatabase.MIGRATION_2.migrate(db)
+ }
+ }
+ val MIGRATION_3_4 = object : Migration(3, 4) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ AddressDatabase.MIGRATION_4.migrate(db)
+ PublicAddressDatabase.MIGRATION_2.migrate(db)
+ KeyTransparencyDatabase.MIGRATION_0.migrate(db)
+ }
+ }
+
+ val MIGRATION_4_5 = object : Migration(4, 5) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ MessageDatabase.MIGRATION_1.migrate(db)
+ }
+
+ }
+
+ val MIGRATION_5_6 = object : Migration(5, 6) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ UserDatabase.MIGRATION_2.migrate(db)
+ }
+ }
+
+ val MIGRATION_6_7 = object : Migration(6, 7) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ NotificationDatabase.MIGRATION_0.migrate(db)
+ NotificationDatabase.MIGRATION_1.migrate(db)
+ PushDatabase.MIGRATION_0.migrate(db)
+ }
+ }
+
+ val MIGRATION_7_8 = object : Migration(7, 8) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ UserSettingsDatabase.MIGRATION_2.migrate(db)
+ }
+ }
+
+ val MIGRATION_8_9 = object : Migration(8, 9) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ MessageDatabase.MIGRATION_2.migrate(db)
+ }
+ }
+
+ val MIGRATION_9_10 = object : Migration(9, 10) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ MessageDatabase.MIGRATION_3.migrate(db)
+ }
+ }
+
+ val MIGRATION_10_11 = object : Migration(10, 11) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ DraftStateDatabase.MIGRATION_0.migrate(db)
+ }
+ }
+
+ val MIGRATION_11_12 = object : Migration(11, 12) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ ContactDatabase.MIGRATION_1.migrate(db)
+ EventMetadataDatabase.MIGRATION_1.migrate(db)
+ }
+ }
+
+ val MIGRATION_12_13 = object : Migration(12, 13) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ MessageDatabase.MIGRATION_4.migrate(db)
+ DraftStateDatabase.MIGRATION_1.migrate(db)
+ }
+ }
+
+ val MIGRATION_13_14 = object : Migration(13, 14) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ UserDatabase.MIGRATION_3.migrate(db)
+ AccountDatabase.MIGRATION_6.migrate(db)
+ }
+ }
+
+ val MIGRATION_14_15 = object : Migration(14, 15) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ TelemetryDatabase.MIGRATION_0.migrate(db)
+ UserSettingsDatabase.MIGRATION_3.migrate(db)
+ }
+ }
+
+ val MIGRATION_15_16 = object : Migration(15, 16) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ DraftStateDatabase.MIGRATION_2.migrate(db)
+ }
+ }
+
+ val MIGRATION_16_17 = object : Migration(16, 17) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ DraftStateDatabase.MIGRATION_3.migrate(db)
+ }
+ }
+
+ val MIGRATION_17_18 = object : Migration(17, 18) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ MessageDatabase.MIGRATION_5.migrate(db)
+ }
+
+ }
+
+ val MIGRATION_18_19 = object : Migration(18, 19) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ EventMetadataDatabase.MIGRATION_2.migrate(db)
+ }
+ }
+
+ val MIGRATION_19_20 = object : Migration(19, 20) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ UserSettingsDatabase.MIGRATION_4.migrate(db)
+ }
+ }
+
+ val MIGRATION_20_21 = object : Migration(20, 21) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ DraftStateDatabase.MIGRATION_4.migrate(db)
+ }
+ }
+
+ val MIGRATION_21_22 = object : Migration(21, 22) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // Empty migration as UnreadCountDatabase was deleted
+ // when tables were distributed to message and conversation DBs (migration 23->24)
+ }
+ }
+
+ val MIGRATION_22_23 = object : Migration(22, 23) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ DraftStateDatabase.MIGRATION_5.migrate(db)
+ }
+ }
+
+ val MIGRATION_23_24 = object : Migration(23, 24) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // Add UnreadMessageCount Table
+ MessageDatabase.MIGRATION_6.migrate(db)
+ // Add UnreadConversationsCount Table
+ ConversationDatabase.MIGRATION_0.migrate(db)
+ }
+ }
+
+
+ val MIGRATION_24_25 = object : Migration(24, 25) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // Create SearchResults Table
+ SearchResultsDatabase.MIGRATION_0.migrate(db)
+ }
+ }
+
+ val MIGRATION_25_26 = object : Migration(25, 26) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ DraftStateDatabase.MIGRATION_6.migrate(db)
+ }
+ }
+
+ val MIGRATION_26_27 = object : Migration(26, 27) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ DraftStateDatabase.MIGRATION_7.migrate(db)
+ }
+ }
+
+ val MIGRATION_27_28 = object : Migration(27, 28) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ UserSettingsDatabase.MIGRATION_5.migrate(db)
+ UserKeyDatabase.MIGRATION_0.migrate(db)
+ UserDatabase.MIGRATION_4.migrate(db)
+ }
+ }
+
+ val MIGRATION_28_29 = object : Migration(28, 29) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ DraftStateDatabase.MIGRATION_8.migrate(db)
+ }
+ }
+
+ val MIGRATION_29_30 = object : Migration(29, 30) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ MessageDatabase.MIGRATION_7.migrate(db)
+ }
+ }
+
+ val MIGRATION_30_31 = object : Migration(30, 31) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ UserDatabase.MIGRATION_5.migrate(db)
+ AccountDatabase.MIGRATION_7.migrate(db)
+ }
+ }
+
+ val MIGRATION_31_32 = object : Migration(31, 32) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ PaymentDatabase.MIGRATION_1.migrate(db)
+ }
+ }
+
+ val MIGRATION_32_33 = object : Migration(32, 33) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ UserSettingsDatabase.MIGRATION_6.migrate(db)
+ }
+ }
+
+ val MIGRATION_33_34 = object : Migration(33, 34) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ DeviceRecoveryDatabase.MIGRATION_0.migrate(db)
+ DeviceRecoveryDatabase.MIGRATION_1.migrate(db)
+ UserKeyDatabase.MIGRATION_1.migrate(db)
+ }
+ }
+
+ val MIGRATION_34_35 = object : Migration(34, 35) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ AccountDatabase.MIGRATION_8.migrate(db)
+ UserSettingsDatabase.MIGRATION_7.migrate(db)
+ PublicAddressDatabase.MIGRATION_3.migrate(db)
+ EventMetadataDatabase.MIGRATION_3.migrate(db)
+ }
+ }
+
+ val MIGRATION_35_36 = object : Migration(35, 36) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ ContactDatabase.MIGRATION_2.migrate(db)
+ }
+ }
+
+ val MIGRATION_36_37 = object : Migration(36, 37) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ AuthDatabase.MIGRATION_0.migrate(db)
+ AuthDatabase.MIGRATION_1.migrate(db)
+ }
+ }
+
+ val MIGRATION_37_38 = object : Migration(37, 38) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ AuthDatabase.MIGRATION_2.migrate(db)
+ AuthDatabase.MIGRATION_3.migrate(db)
+ AuthDatabase.MIGRATION_4.migrate(db)
+ AuthDatabase.MIGRATION_5.migrate(db)
+ UserDatabase.MIGRATION_6.migrate(db)
+ AccountDatabase.MIGRATION_9.migrate(db)
+ MailSettingsDatabase.MIGRATION_1.migrate(db)
+ }
+ }
+
+ val MIGRATION_38_39 = object : Migration(38, 39) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ MailSettingsDatabase.MIGRATION_2.migrate(db)
+ MailSettingsDatabase.MIGRATION_3.migrate(db)
+ }
+ }
+
+ val MIGRATION_39_40 = object : Migration(39, 40) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ UserSettingsDatabase.MIGRATION_8.migrate(db)
+ }
+ }
+
+ val MIGRATION_40_41 = object : Migration(40, 41) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ AccountDatabase.MIGRATION_10.migrate(db)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/di/AppDatabaseModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/AppDatabaseModule.kt
new file mode 100644
index 0000000000..e4911bec2b
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/di/AppDatabaseModule.kt
@@ -0,0 +1,150 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import android.content.Context
+import androidx.room.RoomDatabase
+import ch.protonmail.android.composer.data.local.DraftStateDatabase
+import ch.protonmail.android.db.AppDatabase
+import ch.protonmail.android.mailconversation.data.local.ConversationDatabase
+import ch.protonmail.android.mailmessage.data.local.MessageDatabase
+import ch.protonmail.android.mailmessage.data.local.SearchResultsDatabase
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import me.proton.core.account.data.db.AccountDatabase
+import me.proton.core.auth.data.db.AuthDatabase
+import me.proton.core.challenge.data.db.ChallengeDatabase
+import me.proton.core.contact.data.local.db.ContactDatabase
+import me.proton.core.eventmanager.data.db.EventMetadataDatabase
+import me.proton.core.featureflag.data.db.FeatureFlagDatabase
+import me.proton.core.humanverification.data.db.HumanVerificationDatabase
+import me.proton.core.key.data.db.KeySaltDatabase
+import me.proton.core.key.data.db.PublicAddressDatabase
+import me.proton.core.keytransparency.data.local.KeyTransparencyDatabase
+import me.proton.core.label.data.local.LabelDatabase
+import me.proton.core.mailsettings.data.db.MailSettingsDatabase
+import me.proton.core.notification.data.local.db.NotificationDatabase
+import me.proton.core.observability.data.db.ObservabilityDatabase
+import me.proton.core.payment.data.local.db.PaymentDatabase
+import me.proton.core.push.data.local.db.PushDatabase
+import me.proton.core.telemetry.data.db.TelemetryDatabase
+import me.proton.core.user.data.db.AddressDatabase
+import me.proton.core.user.data.db.UserDatabase
+import me.proton.core.userrecovery.data.db.DeviceRecoveryDatabase
+import me.proton.core.usersettings.data.db.OrganizationDatabase
+import me.proton.core.usersettings.data.db.UserSettingsDatabase
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppDatabaseModule {
+ @Provides
+ @Singleton
+ fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase = AppDatabase.buildDatabase(context)
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+@Suppress("TooManyFunctions")
+abstract class AppDatabaseBindsModule {
+ @Binds
+ abstract fun provideRoomDatabase(appDatabase: AppDatabase): RoomDatabase
+
+ @Binds
+ abstract fun provideAccountDatabase(appDatabase: AppDatabase): AccountDatabase
+
+ @Binds
+ abstract fun provideUserDatabase(appDatabase: AppDatabase): UserDatabase
+
+ @Binds
+ abstract fun provideAddressDatabase(appDatabase: AppDatabase): AddressDatabase
+
+ @Binds
+ abstract fun provideKeySaltDatabase(appDatabase: AppDatabase): KeySaltDatabase
+
+ @Binds
+ abstract fun providePublicAddressDatabase(appDatabase: AppDatabase): PublicAddressDatabase
+
+ @Binds
+ abstract fun provideHumanVerificationDatabase(appDatabase: AppDatabase): HumanVerificationDatabase
+
+ @Binds
+ abstract fun provideMailSettingsDatabase(appDatabase: AppDatabase): MailSettingsDatabase
+
+ @Binds
+ abstract fun provideUserSettingsDatabase(appDatabase: AppDatabase): UserSettingsDatabase
+
+ @Binds
+ abstract fun provideOrganizationDatabase(appDatabase: AppDatabase): OrganizationDatabase
+
+ @Binds
+ abstract fun provideContactDatabase(appDatabase: AppDatabase): ContactDatabase
+
+ @Binds
+ abstract fun provideEventMetadataDatabase(appDatabase: AppDatabase): EventMetadataDatabase
+
+ @Binds
+ abstract fun provideLabelDatabase(appDatabase: AppDatabase): LabelDatabase
+
+ @Binds
+ abstract fun provideFeatureFlagDatabase(appDatabase: AppDatabase): FeatureFlagDatabase
+
+ @Binds
+ abstract fun provideChallengeDatabase(appDatabase: AppDatabase): ChallengeDatabase
+
+ @Binds
+ abstract fun provideMessageDatabase(appDatabase: AppDatabase): MessageDatabase
+
+ @Binds
+ abstract fun provideConversationDatabase(appDatabase: AppDatabase): ConversationDatabase
+
+ @Binds
+ abstract fun providePaymentDatabase(appDatabase: AppDatabase): PaymentDatabase
+
+ @Binds
+ abstract fun provideObservabilityDatabase(appDatabase: AppDatabase): ObservabilityDatabase
+
+ @Binds
+ abstract fun provideKeyTransparencyDatabase(appDatabase: AppDatabase): KeyTransparencyDatabase
+
+ @Binds
+ abstract fun provideNotificationDatabase(appDatabase: AppDatabase): NotificationDatabase
+
+ @Binds
+ abstract fun providePushDatabase(appDatabase: AppDatabase): PushDatabase
+
+ @Binds
+ abstract fun provideTelemetryDatabase(appDatabase: AppDatabase): TelemetryDatabase
+
+ @Binds
+ abstract fun provideDraftStateDatabase(appDatabase: AppDatabase): DraftStateDatabase
+
+ @Binds
+ abstract fun provideSearchResultsDatabase(appDatabase: AppDatabase): SearchResultsDatabase
+
+ @Binds
+ abstract fun provideDeviceRecoveryDatabase(appDatabase: AppDatabase): DeviceRecoveryDatabase
+
+ @Binds
+ abstract fun provideAuthDatabase(appDatabase: AppDatabase): AuthDatabase
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/di/ApplicationModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/ApplicationModule.kt
new file mode 100644
index 0000000000..1e364a5c7e
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/di/ApplicationModule.kt
@@ -0,0 +1,95 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import android.content.Context
+import androidx.work.WorkManager
+import ch.protonmail.android.BuildConfig
+import ch.protonmail.android.mailcommon.domain.AppInformation
+import ch.protonmail.android.mailnotifications.domain.NotificationsDeepLinkHelper
+import ch.protonmail.android.navigation.deeplinks.NotificationsDeepLinkHelperImpl
+import com.google.android.play.core.review.ReviewManager
+import com.google.android.play.core.review.ReviewManagerFactory
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import me.proton.core.account.domain.entity.AccountType
+import me.proton.core.compose.theme.AppTheme
+import me.proton.core.compose.theme.ProtonTheme
+import me.proton.core.configuration.EnvironmentConfiguration
+import me.proton.core.domain.entity.AppStore
+import me.proton.core.domain.entity.Product
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ApplicationModule {
+
+ @Qualifier
+ @Retention(AnnotationRetention.BINARY)
+ annotation class LocalDiskOpCoroutineScope
+
+ @Provides
+ @Singleton
+ fun provideProduct(): Product = Product.Mail
+
+ @Provides
+ @Singleton
+ fun provideAppStore() = AppStore.GooglePlay
+
+ @Provides
+ fun provideAppTheme() = AppTheme { content ->
+ ProtonTheme { content() }
+ }
+
+ @Provides
+ @Singleton
+ fun provideAppInfo(envConfig: EnvironmentConfiguration): AppInformation = AppInformation(
+ appName = "Proton Mail",
+ appVersionName = BuildConfig.VERSION_NAME,
+ appVersionCode = BuildConfig.VERSION_CODE,
+ appBuildType = BuildConfig.BUILD_TYPE,
+ appBuildFlavor = BuildConfig.FLAVOR,
+ appHost = envConfig.host
+ )
+
+ @Provides
+ @Singleton
+ fun provideRequiredAccountType(): AccountType = AccountType.Internal
+
+ @Provides
+ @Singleton
+ fun provideWorkManager(@ApplicationContext context: Context): WorkManager = WorkManager.getInstance(context)
+
+ @Provides
+ @Singleton
+ fun provideReviewManager(@ApplicationContext context: Context): ReviewManager = ReviewManagerFactory.create(context)
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ interface BindsModule {
+
+ @Binds
+ fun bindNotificationsDeepLinkHelper(impl: NotificationsDeepLinkHelperImpl): NotificationsDeepLinkHelper
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/di/AuthModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/AuthModule.kt
new file mode 100644
index 0000000000..3e9bc3ab0c
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/di/AuthModule.kt
@@ -0,0 +1,53 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.auth.domain.usecase.PostLoginAccountSetup
+import me.proton.core.auth.presentation.DefaultHelpOptionHandler
+import me.proton.core.auth.presentation.DefaultUserCheck
+import me.proton.core.auth.presentation.HelpOptionHandler
+import me.proton.core.user.domain.UserManager
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AuthModule {
+ @Provides
+ @Singleton
+ fun provideUserCheck(
+ @ApplicationContext context: Context,
+ accountManager: AccountManager,
+ userManager: UserManager
+ ): PostLoginAccountSetup.UserCheck = DefaultUserCheck(
+ context,
+ accountManager,
+ userManager
+ )
+
+ @Provides
+ @Singleton
+ fun provideHelpOptionHandler(): HelpOptionHandler = DefaultHelpOptionHandler()
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/di/AutoLockModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/AutoLockModule.kt
new file mode 100644
index 0000000000..59ccdf90b8
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/di/AutoLockModule.kt
@@ -0,0 +1,37 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import ch.protonmail.android.mailsettings.domain.handler.ForegroundAwareAutoLockHandler
+import dagger.Module
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal interface AutoLockModule {
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface EntryPointModule {
+
+ fun autoLockHandler(): ForegroundAwareAutoLockHandler
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/di/BenchmarkModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/BenchmarkModule.kt
new file mode 100644
index 0000000000..289a2d21ec
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/di/BenchmarkModule.kt
@@ -0,0 +1,37 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import ch.protonmail.android.mailcommon.domain.benchmark.BenchmarkTracer
+import ch.protonmail.android.mailcommon.domain.benchmark.BenchmarkTracerImpl
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object BenchmarkModule {
+
+ @Provides
+ @Singleton
+ fun provideBenchmarkTracer(@BuildType buildType: String): BenchmarkTracer =
+ BenchmarkTracerImpl(buildType == "benchmark")
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/di/BuildConfigModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/BuildConfigModule.kt
new file mode 100644
index 0000000000..789a3b5a40
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/di/BuildConfigModule.kt
@@ -0,0 +1,55 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import ch.protonmail.android.BuildConfig
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Qualifier
+
+@Module
+@InstallIn(SingletonComponent::class)
+object BuildConfigModule {
+
+ @Provides
+ @BuildFlavor
+ fun provideBuildFlavor() = BuildConfig.FLAVOR
+
+ @Provides
+ @BuildDebug
+ fun provideBuildDebug() = BuildConfig.DEBUG
+
+ @Provides
+ @BuildType
+ fun provideBuildType() = BuildConfig.BUILD_TYPE
+}
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class BuildFlavor
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class BuildDebug
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class BuildType
diff --git a/app/src/main/kotlin/ch/protonmail/android/di/EventManagerModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/EventManagerModule.kt
new file mode 100644
index 0000000000..704b1c32a0
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/di/EventManagerModule.kt
@@ -0,0 +1,94 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import ch.protonmail.android.mailconversation.data.ConversationEventListener
+import ch.protonmail.android.mailconversation.data.UnreadConversationsCountEventListener
+import ch.protonmail.android.mailmessage.data.UnreadMessagesCountEventListener
+import ch.protonmail.android.mailmessage.data.MessageEventListener
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.ElementsIntoSet
+import me.proton.core.contact.data.ContactEmailEventListener
+import me.proton.core.contact.data.ContactEventListener
+import me.proton.core.eventmanager.data.EventManagerQueryMapProvider
+import me.proton.core.eventmanager.domain.EventListener
+import me.proton.core.label.data.LabelEventListener
+import me.proton.core.mailsettings.data.MailSettingsEventListener
+import me.proton.core.notification.data.NotificationEventListener
+import me.proton.core.push.data.PushEventListener
+import me.proton.core.user.data.UserAddressEventListener
+import me.proton.core.user.data.UserEventListener
+import me.proton.core.user.data.UserSpaceEventListener
+import me.proton.core.usersettings.data.UserSettingsEventListener
+import javax.inject.Singleton
+
+@Module(includes = [EventManagerModule.BindersModule::class])
+@InstallIn(SingletonComponent::class)
+@Suppress("LongParameterList")
+object EventManagerModule {
+
+ @Provides
+ @Singleton
+ @ElementsIntoSet
+ @JvmSuppressWildcards
+ fun provideEventListenerSet(
+ userEventListener: UserEventListener,
+ userAddressEventListener: UserAddressEventListener,
+ userSettingsEventListener: UserSettingsEventListener,
+ mailSettingsEventListener: MailSettingsEventListener,
+ contactEventListener: ContactEventListener,
+ contactEmailEventListener: ContactEmailEventListener,
+ labelEventListener: LabelEventListener,
+ messageEventListener: MessageEventListener,
+ conversationEventListener: ConversationEventListener,
+ notificationEventListener: NotificationEventListener,
+ pushEventListener: PushEventListener,
+ unreadMessagesCountEventListener: UnreadMessagesCountEventListener,
+ unreadConversationsCountEventListener: UnreadConversationsCountEventListener,
+ userSpaceEventListener: UserSpaceEventListener
+ ): Set> = setOf(
+ userEventListener,
+ userAddressEventListener,
+ userSettingsEventListener,
+ userSpaceEventListener,
+ mailSettingsEventListener,
+ contactEventListener,
+ contactEmailEventListener,
+ labelEventListener,
+ messageEventListener,
+ conversationEventListener,
+ notificationEventListener,
+ pushEventListener,
+ unreadMessagesCountEventListener,
+ unreadConversationsCountEventListener
+ )
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ internal interface BindersModule {
+
+ @Binds
+ @Singleton
+ fun bindsEventManagerQueryMapProvider(impl: MailEventManagerQueryMapProvider): EventManagerQueryMapProvider
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/di/FeatureFlagModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/FeatureFlagModule.kt
new file mode 100644
index 0000000000..49b1253cc1
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/di/FeatureFlagModule.kt
@@ -0,0 +1,43 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import ch.protonmail.android.mailcommon.domain.MailFeatureDefaults
+import ch.protonmail.android.mailcommon.domain.MailFeatureId
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object FeatureFlagModule {
+
+ @Provides
+ @Singleton
+ fun provideDefaultMailFeatureFlags(): MailFeatureDefaults {
+ return MailFeatureDefaults(
+ mapOf(
+ MailFeatureId.ConversationMode to true,
+ MailFeatureId.RatingBooster to false
+ )
+ )
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/di/HumanVerificationModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/HumanVerificationModule.kt
new file mode 100644
index 0000000000..69813c6942
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/di/HumanVerificationModule.kt
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import me.proton.core.configuration.EnvironmentConfiguration
+import me.proton.core.humanverification.presentation.HumanVerificationApiHost
+import me.proton.core.humanverification.presentation.utils.HumanVerificationVersion
+
+@Module
+@InstallIn(SingletonComponent::class)
+object HumanVerificationModule {
+
+ @Provides
+ @HumanVerificationApiHost
+ fun provideHumanVerificationApiHost(envConfig: EnvironmentConfiguration): String = "https://${envConfig.hv3Host}/"
+
+ @Provides
+ fun provideHumanVerificationVersion() = HumanVerificationVersion.HV3
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/di/MailEventManagerQueryMapProvider.kt b/app/src/main/kotlin/ch/protonmail/android/di/MailEventManagerQueryMapProvider.kt
new file mode 100644
index 0000000000..c1b8bafd76
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/di/MailEventManagerQueryMapProvider.kt
@@ -0,0 +1,32 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import me.proton.core.eventmanager.data.EventManagerQueryMapProvider
+import me.proton.core.eventmanager.domain.EventManagerConfig
+import javax.inject.Inject
+
+class MailEventManagerQueryMapProvider @Inject constructor() : EventManagerQueryMapProvider {
+
+ override suspend fun getQueryMap(config: EventManagerConfig): Map = when (config) {
+ is EventManagerConfig.Core -> mapOf("MessageCounts" to "1", "ConversationCounts" to "1")
+ else -> emptyMap()
+ }
+
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/di/NetworkModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/NetworkModule.kt
new file mode 100644
index 0000000000..0e4d486caf
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/di/NetworkModule.kt
@@ -0,0 +1,111 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import ch.protonmail.android.BuildConfig
+import ch.protonmail.android.di.ApplicationModule.LocalDiskOpCoroutineScope
+import ch.protonmail.android.feature.alternativerouting.HasAlternativeRouting
+import ch.protonmail.android.feature.forceupdate.ForceUpdateHandler
+import ch.protonmail.android.useragent.BuildUserAgent
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import me.proton.core.configuration.EnvironmentConfiguration
+import me.proton.core.network.data.client.ExtraHeaderProviderImpl
+import me.proton.core.network.data.di.AlternativeApiPins
+import me.proton.core.network.data.di.BaseProtonApiUrl
+import me.proton.core.network.data.di.CertificatePins
+import me.proton.core.network.data.di.Constants
+import me.proton.core.network.data.di.DohProviderUrls
+import me.proton.core.network.domain.ApiClient
+import me.proton.core.network.domain.client.ExtraHeaderProvider
+import me.proton.core.network.domain.serverconnection.DohAlternativesListener
+import me.proton.core.util.kotlin.takeIfNotBlank
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+@Suppress("LongParameterList")
+object NetworkModule {
+ @Provides
+ @Singleton
+ fun provideApiClient(
+ buildUserAgent: BuildUserAgent,
+ forceUpdateHandler: ForceUpdateHandler,
+ hasAlternativeRouting: HasAlternativeRouting
+ ) = object : ApiClient {
+ override val appVersionHeader: String
+ get() = "android-mail@${BuildConfig.VERSION_NAME}"
+ override val enableDebugLogging: Boolean
+ get() = BuildConfig.DEBUG
+ override val shouldUseDoh: Boolean
+ get() = hasAlternativeRouting().value.isEnabled
+ override val userAgent: String
+ get() = buildUserAgent()
+
+ override fun forceUpdate(errorMessage: String) {
+ forceUpdateHandler.onForceUpdate(errorMessage)
+ }
+ }
+
+ @Provides
+ @Singleton
+ @LocalDiskOpCoroutineScope
+ fun provideLocalDiskOpCoroutineScope(): CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ @Provides
+ @Singleton
+ fun provideExtraHeaderProvider(): ExtraHeaderProvider = ExtraHeaderProviderImpl().apply {
+ val proxyToken: String? = BuildConfig.PROXY_TOKEN
+ proxyToken?.takeIfNotBlank()?.let { addHeaders("X-atlas-secret" to it) }
+ }
+
+ @DohProviderUrls
+ @Provides
+ fun provideDohProviderUrls(): Array = Constants.DOH_PROVIDERS_URLS
+
+ @CertificatePins
+ @Provides
+ fun provideCertificatePins(): Array =
+ Constants.DEFAULT_SPKI_PINS.takeIf { BuildConfig.USE_DEFAULT_PINS } ?: emptyArray()
+
+ @AlternativeApiPins
+ @Provides
+ fun provideAlternativeApiPins(): List =
+ Constants.ALTERNATIVE_API_SPKI_PINS.takeIf { BuildConfig.USE_DEFAULT_PINS } ?: emptyList()
+
+ @Provides
+ @Singleton
+ fun provideDohAlternativesListener(): DohAlternativesListener? = null
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkConfigModule {
+
+ @Provides
+ @BaseProtonApiUrl
+ fun provideProtonApiUrl(envConfig: EnvironmentConfiguration): HttpUrl = envConfig.baseUrl.toHttpUrl()
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/account/RemoveAccountDialog.kt b/app/src/main/kotlin/ch/protonmail/android/feature/account/RemoveAccountDialog.kt
new file mode 100644
index 0000000000..0d7dad287a
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/feature/account/RemoveAccountDialog.kt
@@ -0,0 +1,106 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.feature.account
+
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import ch.protonmail.android.R
+import ch.protonmail.android.feature.account.SignOutAccountViewModel.State
+import me.proton.core.compose.component.ProtonAlertDialog
+import me.proton.core.compose.component.ProtonAlertDialogText
+import me.proton.core.compose.component.ProtonTextButton
+import me.proton.core.compose.theme.ProtonTheme
+import me.proton.core.compose.theme.defaultStrongNorm
+import me.proton.core.domain.entity.UserId
+
+@Composable
+fun RemoveAccountDialog(
+ modifier: Modifier = Modifier,
+ userId: UserId? = null,
+ onCancelled: () -> Unit,
+ onRemoved: () -> Unit,
+ viewModel: SignOutAccountViewModel = hiltViewModel()
+) {
+ val viewState by viewModel.state.collectAsStateWithLifecycle()
+
+ when (viewState) {
+ State.Removed -> onRemoved()
+ else -> Unit
+ }
+
+ RemoveAccountDialog(
+ modifier = modifier,
+ viewState = viewState,
+ onCancelClicked = onCancelled,
+ onRemoveClicked = { viewModel.signOut(userId, removeAccount = true) }
+ )
+}
+
+@Composable
+private fun RemoveAccountDialog(
+ modifier: Modifier = Modifier,
+ viewState: State,
+ onCancelClicked: () -> Unit,
+ onRemoveClicked: () -> Unit
+) {
+ ProtonAlertDialog(
+ modifier = modifier,
+ onDismissRequest = onCancelClicked,
+ title = stringResource(id = R.string.dialog_remove_account_title),
+ text = { ProtonAlertDialogText(text = stringResource(id = R.string.dialog_remove_account_description)) },
+ confirmButton = {
+ ProtonTextButton(
+ onClick = onRemoveClicked,
+ content = {
+ when (viewState) {
+ State.Initial,
+ State.Removed -> Text(
+ text = stringResource(id = R.string.dialog_remove_account_confirm),
+ style = ProtonTheme.typography.defaultStrongNorm,
+ color = ProtonTheme.colors.textAccent
+ )
+
+ State.Removing -> CircularProgressIndicator()
+ else -> Unit
+ }
+ }
+ )
+ },
+ dismissButton = {
+ ProtonTextButton(onClick = onCancelClicked) {
+ Text(
+ text = stringResource(id = R.string.dialog_remove_account_cancel),
+ style = ProtonTheme.typography.defaultStrongNorm,
+ color = ProtonTheme.colors.textAccent
+ )
+ }
+ }
+ )
+}
+
+object RemoveAccountDialog {
+
+ const val USER_ID_KEY = "user id"
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/account/SignOutAccountDialog.kt b/app/src/main/kotlin/ch/protonmail/android/feature/account/SignOutAccountDialog.kt
new file mode 100644
index 0000000000..26312e064e
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/feature/account/SignOutAccountDialog.kt
@@ -0,0 +1,69 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.feature.account
+
+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.feature.account.SignOutAccountViewModel.State
+import me.proton.core.accountmanager.presentation.compose.SignOutDialog
+import me.proton.core.domain.entity.UserId
+
+@Composable
+fun SignOutAccountDialog(
+ modifier: Modifier = Modifier,
+ userId: UserId? = null,
+ actions: SignOutAccountDialog.Actions,
+ viewModel: SignOutAccountViewModel = hiltViewModel()
+) {
+ val viewState by viewModel.state.collectAsStateWithLifecycle()
+
+ when (viewState) {
+ State.SignedOut -> actions.onSignedOut()
+ State.Removed -> actions.onRemoved()
+ else -> Unit
+ }
+
+ SignOutDialog(
+ modifier = modifier,
+ onDismiss = actions.onCancelled,
+ onDisableAccount = { viewModel.signOut(userId, removeAccount = false) },
+ onRemoveAccount = { viewModel.signOut(userId, removeAccount = true) }
+ )
+}
+
+object SignOutAccountDialog {
+
+ const val USER_ID_KEY = "user id"
+
+ data class Actions(
+ val onSignedOut: () -> Unit,
+ val onRemoved: () -> Unit,
+ val onCancelled: () -> Unit
+ )
+}
+
+object SignOutAccountDialogTestTags {
+
+ const val RootItem = "SignOutAccountDialogRootItem"
+ const val YesButton = "YesButton"
+ const val NoButton = "NoButton"
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/account/SignOutAccountViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/feature/account/SignOutAccountViewModel.kt
new file mode 100644
index 0000000000..06ebf79b0d
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/feature/account/SignOutAccountViewModel.kt
@@ -0,0 +1,66 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.feature.account
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import ch.protonmail.android.mailcommon.data.worker.Enqueuer
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.launch
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.domain.entity.UserId
+import javax.inject.Inject
+
+@HiltViewModel
+class SignOutAccountViewModel @Inject constructor(
+ private val accountManager: AccountManager,
+ private val enqueuer: Enqueuer
+) : ViewModel() {
+
+ private val mutableState = MutableStateFlow(State.Initial)
+ val state = mutableState.asStateFlow()
+
+ fun signOut(userId: UserId? = null, removeAccount: Boolean = false) = viewModelScope.launch {
+ val resolvedUserId = requireNotNull(userId ?: getPrimaryUserIdOrNull())
+ enqueuer.cancelAllWork(resolvedUserId)
+
+ if (removeAccount) {
+ mutableState.emit(State.Removing)
+ accountManager.removeAccount(resolvedUserId)
+ mutableState.emit(State.Removed)
+ } else {
+ mutableState.emit(State.SigningOut)
+ accountManager.disableAccount(resolvedUserId)
+ mutableState.emit(State.SignedOut)
+ }
+ }
+
+ private suspend fun getPrimaryUserIdOrNull() = accountManager.getPrimaryUserId().firstOrNull()
+
+ sealed class State {
+ object Initial : State()
+ object SigningOut : State()
+ object SignedOut : State()
+ object Removing : State()
+ object Removed : State()
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/alternativerouting/HasAlternativeRouting.kt b/app/src/main/kotlin/ch/protonmail/android/feature/alternativerouting/HasAlternativeRouting.kt
new file mode 100644
index 0000000000..0d6688d6e6
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/feature/alternativerouting/HasAlternativeRouting.kt
@@ -0,0 +1,50 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.feature.alternativerouting
+
+import ch.protonmail.android.di.ApplicationModule.LocalDiskOpCoroutineScope
+import ch.protonmail.android.mailsettings.domain.model.AlternativeRoutingPreference
+import ch.protonmail.android.mailsettings.domain.repository.AlternativeRoutingRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import javax.inject.Inject
+
+class HasAlternativeRouting @Inject constructor(
+ private val alternativeRoutingRepository: AlternativeRoutingRepository,
+ @LocalDiskOpCoroutineScope
+ private val coroutineScope: CoroutineScope
+) {
+
+ private val initialValue = AlternativeRoutingPreference(true)
+
+ operator fun invoke() = alternativeRoutingRepository.observe()
+ .map { alternativeRoutingPreferenceEither ->
+ alternativeRoutingPreferenceEither.fold(
+ ifLeft = { initialValue },
+ ifRight = { it }
+ )
+ }
+ .stateIn(
+ scope = coroutineScope,
+ started = SharingStarted.Eagerly,
+ initialValue = initialValue
+ )
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/forceupdate/ForceUpdateHandler.kt b/app/src/main/kotlin/ch/protonmail/android/feature/forceupdate/ForceUpdateHandler.kt
new file mode 100644
index 0000000000..79cce6d72a
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/feature/forceupdate/ForceUpdateHandler.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.feature.forceupdate
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import me.proton.core.presentation.app.AppLifecycleObserver
+import me.proton.core.presentation.app.AppLifecycleProvider
+import me.proton.core.presentation.ui.alert.ForceUpdateActivity
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ForceUpdateHandler @Inject constructor(
+ @ApplicationContext
+ private val context: Context,
+ private val appLifecycleObserver: AppLifecycleObserver
+) {
+ fun onForceUpdate(errorMessage: String) {
+ if (appLifecycleObserver.state.value == AppLifecycleProvider.State.Foreground) {
+ startForceUpdateActivity(errorMessage)
+ }
+ }
+
+ private fun startForceUpdateActivity(errorMessage: String) {
+ context.startActivity(ForceUpdateActivity(context, errorMessage))
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscription.kt b/app/src/main/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscription.kt
new file mode 100644
index 0000000000..7e38955572
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscription.kt
@@ -0,0 +1,91 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.feature.postsubscription
+
+import java.lang.ref.WeakReference
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import ch.protonmail.android.PostSubscriptionActivity
+import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUser
+import ch.protonmail.android.mailupselling.domain.model.UserUpgradeState
+import ch.protonmail.android.mailupselling.domain.model.UserUpgradeState.UserUpgradeCheckState
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.map
+import me.proton.core.user.domain.extension.hasSubscriptionForMail
+import javax.inject.Inject
+
+class ObservePostSubscription @Inject constructor(
+ private val observePostSubscriptionFlowEnabled: ObservePostSubscriptionFlowEnabled,
+ private val observePrimaryUser: ObservePrimaryUser,
+ private val userUpgradeState: UserUpgradeState
+) {
+
+ suspend fun start(activity: AppCompatActivity) {
+ val activityReference = WeakReference(activity)
+ var startedPendingPurchase = false
+ observePrimaryUser()
+ .filterNotNull()
+ .map { it to it.hasSubscriptionForMail() }
+ .distinctUntilChangedBy { it.second }
+ .collectLatest { (user, hasSubscription) ->
+ if (hasSubscription) {
+ if (startedPendingPurchase) {
+ startedPendingPurchase = false
+ activityReference.showPostSubscription()
+ }
+ return@collectLatest
+ }
+ observePostSubscriptionFlowEnabled(user.userId)
+ .filter { it?.value == true }
+ .distinctUntilChanged()
+ .collectLatest innerCollector@{
+ userUpgradeState.userUpgradeCheckState.awaitFlowStarted() ?: return@innerCollector
+ startedPendingPurchase = true
+ val upgradeState = userUpgradeState.userUpgradeCheckState.awaitFlowComplete()
+ startedPendingPurchase = false
+ if (upgradeState == null) return@innerCollector
+ if (upgradeState.upgradedPlanNames.contains(MAIL_PLUS_PLAN_NAME)) {
+ activityReference.showPostSubscription()
+ }
+ }
+ }
+ }
+
+ private fun WeakReference.showPostSubscription() {
+ get()?.let { activity ->
+ activity.startActivity(Intent(activity, PostSubscriptionActivity::class.java))
+ }
+ }
+
+ private suspend fun Flow.awaitFlowStarted() = filter {
+ it == UserUpgradeCheckState.Pending
+ }.firstOrNull()
+
+ private suspend fun Flow.awaitFlowComplete() = filter {
+ it is UserUpgradeCheckState.CompletedWithUpgrade
+ }.firstOrNull() as? UserUpgradeCheckState.CompletedWithUpgrade
+}
+
+private const val MAIL_PLUS_PLAN_NAME = "mail2022"
diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscriptionFlowEnabled.kt b/app/src/main/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscriptionFlowEnabled.kt
new file mode 100644
index 0000000000..f7a34db02c
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscriptionFlowEnabled.kt
@@ -0,0 +1,36 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.feature.postsubscription
+
+import me.proton.core.domain.entity.UserId
+import me.proton.core.featureflag.domain.FeatureFlagManager
+import me.proton.core.featureflag.domain.entity.FeatureId
+import javax.inject.Inject
+
+class ObservePostSubscriptionFlowEnabled @Inject constructor(
+ private val featureFlagManager: FeatureFlagManager
+) {
+
+ operator fun invoke(userId: UserId?) = featureFlagManager.observe(userId, FeatureId(FeatureFlagId))
+
+ private companion object {
+
+ const val FeatureFlagId = "MailAndroidPostSubscription"
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/AccountStateHandlerInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/AccountStateHandlerInitializer.kt
new file mode 100644
index 0000000000..04652285f4
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/AccountStateHandlerInitializer.kt
@@ -0,0 +1,47 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.startup.Initializer
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import me.proton.core.accountmanager.data.AccountStateHandler
+
+class AccountStateHandlerInitializer : Initializer {
+
+ override fun create(context: Context) {
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ AccountStateHandlerInitializerEntryPoint::class.java
+ ).accountStateHandler().start()
+ }
+
+ override fun dependencies(): List?>> = listOf(
+ LoggerInitializer::class.java
+ )
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface AccountStateHandlerInitializerEntryPoint {
+ fun accountStateHandler(): AccountStateHandler
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/AppInBackgroundCheckerInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/AppInBackgroundCheckerInitializer.kt
new file mode 100644
index 0000000000..cf91895df1
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/AppInBackgroundCheckerInitializer.kt
@@ -0,0 +1,61 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
+import androidx.startup.Initializer
+import ch.protonmail.android.mailcommon.domain.AppInBackgroundState
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+
+class AppInBackgroundCheckerInitializer : Initializer, LifecycleEventObserver {
+
+ private var appInBackgroundState: AppInBackgroundState? = null
+ override fun create(context: Context) {
+ appInBackgroundState = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ AppInBackgroundCheckerInitializerEntryPoint::class.java
+ ).appInBackgroundState()
+
+ ProcessLifecycleOwner.get().lifecycle.addObserver(this)
+ }
+
+ override fun dependencies(): List>> = emptyList()
+
+ override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+ when (event) {
+ Lifecycle.Event.ON_RESUME -> appInBackgroundState?.setAppInBackground(false)
+ Lifecycle.Event.ON_PAUSE -> appInBackgroundState?.setAppInBackground(true)
+ else -> Unit
+ }
+ }
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface AppInBackgroundCheckerInitializerEntryPoint {
+
+ fun appInBackgroundState(): AppInBackgroundState
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/AutoLockHandlerInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/AutoLockHandlerInitializer.kt
new file mode 100644
index 0000000000..2636c4f60e
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/AutoLockHandlerInitializer.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.startup.Initializer
+import ch.protonmail.android.di.AutoLockModule
+import dagger.hilt.android.EntryPointAccessors
+
+internal class AutoLockHandlerInitializer : Initializer {
+
+ override fun create(context: Context) {
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ AutoLockModule.EntryPointModule::class.java
+ ).autoLockHandler().handle()
+ }
+
+ override fun dependencies(): List>> = listOf(
+ AppInBackgroundCheckerInitializer::class.java
+ )
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/EventManagerInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/EventManagerInitializer.kt
new file mode 100644
index 0000000000..e15147f724
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/EventManagerInitializer.kt
@@ -0,0 +1,48 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.startup.Initializer
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import me.proton.core.eventmanager.data.CoreEventManagerStarter
+
+class EventManagerInitializer : Initializer {
+
+ override fun create(context: Context) {
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ EventManagerInitializerEntryPoint::class.java
+ ).starter().start()
+ }
+
+ override fun dependencies(): List?>> = listOf(
+ LoggerInitializer::class.java,
+ WorkManagerInitializer::class.java
+ )
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface EventManagerInitializerEntryPoint {
+ fun starter(): CoreEventManagerStarter
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/FeatureFlagInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/FeatureFlagInitializer.kt
new file mode 100644
index 0000000000..9e5642fdb0
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/FeatureFlagInitializer.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2023 Proton AG
+ * This file is part of Proton 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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.startup.Initializer
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import ch.protonmail.android.BuildConfig
+import ch.protonmail.android.initializer.featureflag.RefreshRatingBoosterFeatureFlags
+import me.proton.core.featureflag.data.FeatureFlagRefreshStarter
+
+class FeatureFlagInitializer : Initializer {
+
+ override fun create(context: Context) {
+ val entryPoint = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ FeatureFlagInitializerEntryPoint::class.java
+ )
+ entryPoint.featureFlagRefreshStarter().start(BuildConfig.DEBUG)
+ entryPoint.refreshRatingBoosterFeatureFlags().invoke()
+ }
+
+ override fun dependencies(): List?>> = listOf(
+ WorkManagerInitializer::class.java
+ )
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface FeatureFlagInitializerEntryPoint {
+ fun featureFlagRefreshStarter(): FeatureFlagRefreshStarter
+
+ fun refreshRatingBoosterFeatureFlags(): RefreshRatingBoosterFeatureFlags
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/LoggerInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/LoggerInitializer.kt
new file mode 100644
index 0000000000..ae74fcd8d3
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/LoggerInitializer.kt
@@ -0,0 +1,70 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.startup.Initializer
+import ch.protonmail.android.BuildConfig
+import ch.protonmail.android.mailbugreport.data.FileLoggingTree
+import ch.protonmail.android.mailbugreport.domain.LogsExportFeatureSetting
+import ch.protonmail.android.mailbugreport.domain.LogsFileHandler
+import ch.protonmail.android.mailbugreport.domain.annotations.LogsExportFeatureSettingValue
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import me.proton.core.util.android.sentry.TimberLogger
+import me.proton.core.util.kotlin.CoreLogger
+import timber.log.Timber
+import javax.inject.Provider
+
+class LoggerInitializer : Initializer {
+
+ override fun create(context: Context) {
+ if (BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree())
+ }
+
+ // Forward Core Logs to Timber, using TimberLogger.
+ CoreLogger.set(TimberLogger)
+
+ val accessors = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ LoggerInitializerEntryPoint::class.java
+ )
+
+ val isLoggingEnabled = accessors.logsExportFeatureSetting().get().isEnabled
+ if (isLoggingEnabled.not()) return
+
+ val logsFileHandler = accessors.logsFileHandlerProvider()
+ Timber.plant(FileLoggingTree(logsFileHandler))
+ }
+
+ override fun dependencies(): List>> = emptyList()
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface LoggerInitializerEntryPoint {
+
+ fun logsFileHandlerProvider(): LogsFileHandler
+
+ @LogsExportFeatureSettingValue
+ fun logsExportFeatureSetting(): Provider
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/MainInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/MainInitializer.kt
new file mode 100644
index 0000000000..2801353fd6
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/MainInitializer.kt
@@ -0,0 +1,108 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.startup.AppInitializer
+import androidx.startup.Initializer
+import ch.protonmail.android.BuildConfig
+import ch.protonmail.android.initializer.strictmode.StrictModeInitializer
+import ch.protonmail.android.mailupselling.domain.initializers.UpgradeStateInitializer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import me.proton.core.auth.presentation.MissingScopeInitializer
+import me.proton.core.crypto.validator.presentation.init.CryptoValidatorInitializer
+import me.proton.core.humanverification.presentation.HumanVerificationInitializer
+import me.proton.core.network.presentation.init.UnAuthSessionFetcherInitializer
+import me.proton.core.paymentiap.presentation.GooglePurchaseHandlerInitializer
+import me.proton.core.plan.presentation.PurchaseHandlerInitializer
+import me.proton.core.plan.presentation.UnredeemedPurchaseInitializer
+import me.proton.core.userrecovery.presentation.compose.DeviceRecoveryInitializer
+
+class MainInitializer : Initializer {
+
+ // create a nested class to initialise some of the non-essential time consuming dependencies in a background thread
+ class MainAsyncInitializer : Initializer {
+ override fun create(context: Context) {
+ // No-op needed
+ }
+
+ override fun dependencies() = coreDependencies() + mailDependencies() + releaseOnlyDependenciesIfNeeded()
+
+ private fun coreDependencies() = listOf(
+ FeatureFlagInitializer::class.java
+ )
+
+ private fun mailDependencies(): List?>> = emptyList()
+
+ private fun releaseOnlyDependenciesIfNeeded() =
+ if (BuildConfig.DEBUG) emptyList() else listOf(SentryInitializer::class.java)
+ }
+
+ override fun create(context: Context) {
+ // No-op needed
+ }
+
+ override fun dependencies() = coreDependencies() + mailDependencies()
+
+ private fun coreDependencies() = listOf(
+ CryptoValidatorInitializer::class.java,
+ DeviceRecoveryInitializer::class.java,
+ PurchaseHandlerInitializer::class.java,
+ GooglePurchaseHandlerInitializer::class.java,
+ HumanVerificationInitializer::class.java,
+ MissingScopeInitializer::class.java,
+ UnredeemedPurchaseInitializer::class.java,
+ UnAuthSessionFetcherInitializer::class.java
+ )
+
+ private fun mailDependencies() = listOf(
+ AccountStateHandlerInitializer::class.java,
+ UpgradeStateInitializer::class.java,
+ EventManagerInitializer::class.java,
+ LoggerInitializer::class.java,
+ StrictModeInitializer::class.java,
+ ThemeObserverInitializer::class.java,
+ NotificationInitializer::class.java,
+ NotificationHandlersInitializer::class.java,
+ OutboxInitializer::class.java,
+ AutoLockHandlerInitializer::class.java
+ )
+
+ companion object {
+
+ fun init(appContext: Context) {
+ with(AppInitializer.getInstance(appContext)) {
+ // WorkManager need to be initialized before any other dependant initializer.
+ initializeComponent(WorkManagerInitializer::class.java)
+ initializeComponent(MainInitializer::class.java)
+ }
+
+ // Initialize some non-essential initializers in a background thread. They are taking most
+ // time to initialize. This line must be after initialisation of MainInitializer above, because
+ // AppInitializer has an internal lock, which prevents simultaneous initialisation
+ CoroutineScope(Dispatchers.Default + SupervisorJob()).launch {
+ AppInitializer.getInstance(appContext).initializeComponent(MainAsyncInitializer::class.java)
+ }
+
+ }
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/NotificationHandlersInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/NotificationHandlersInitializer.kt
new file mode 100644
index 0000000000..1260d7a5d3
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/NotificationHandlersInitializer.kt
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.startup.Initializer
+import ch.protonmail.android.mailnotifications.dagger.MailNotificationsModule
+import dagger.hilt.android.EntryPointAccessors
+
+internal class NotificationHandlersInitializer : Initializer {
+
+ override fun create(context: Context) {
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ MailNotificationsModule.EntryPointModule::class.java
+ ).handlers().forEach { it.handle() }
+ }
+
+ override fun dependencies(): List>> = listOf(
+ AccountStateHandlerInitializer::class.java,
+ AppInBackgroundCheckerInitializer::class.java
+ )
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/NotificationInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/NotificationInitializer.kt
new file mode 100644
index 0000000000..85405ccd7a
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/NotificationInitializer.kt
@@ -0,0 +1,47 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.startup.Initializer
+import ch.protonmail.android.mailcommon.presentation.system.NotificationProvider
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+
+class NotificationInitializer : Initializer {
+
+ override fun create(context: Context) {
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ NotificationInitializerEntryPoint::class.java
+ ).notificationProvider().initNotificationChannels()
+ }
+
+ override fun dependencies(): List>> = listOf(AppInBackgroundCheckerInitializer::class.java)
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface NotificationInitializerEntryPoint {
+
+ fun notificationProvider(): NotificationProvider
+ }
+
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/OutboxInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/OutboxInitializer.kt
new file mode 100644
index 0000000000..c47ec28f49
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/OutboxInitializer.kt
@@ -0,0 +1,48 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.startup.Initializer
+import ch.protonmail.android.initializer.outbox.OutboxObserver
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+
+class OutboxInitializer : Initializer {
+
+ override fun create(context: Context) {
+
+ val entryPoint = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ OutboxInitializerEntryPoint::class.java
+ )
+ entryPoint.outboxObserver().start()
+
+ }
+
+ override fun dependencies(): List>> = emptyList()
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface OutboxInitializerEntryPoint {
+ fun outboxObserver(): OutboxObserver
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/SentryInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/SentryInitializer.kt
new file mode 100644
index 0000000000..a98927b280
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/SentryInitializer.kt
@@ -0,0 +1,72 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.startup.Initializer
+import ch.protonmail.android.BuildConfig
+import ch.protonmail.android.logging.SentryUserObserver
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import io.sentry.SentryLevel
+import io.sentry.SentryOptions
+import io.sentry.android.core.SentryAndroid
+import me.proton.core.configuration.EnvironmentConfigurationDefaults
+import me.proton.core.util.android.sentry.TimberLoggerIntegration
+import me.proton.core.util.android.sentry.project.AccountSentryHubBuilder
+
+class SentryInitializer : Initializer {
+
+ override fun create(context: Context) {
+ SentryAndroid.init(context.applicationContext) { options: SentryOptions ->
+ options.dsn = BuildConfig.SENTRY_DSN
+ options.release = BuildConfig.VERSION_NAME
+ options.environment = EnvironmentConfigurationDefaults.host
+ options.addIntegration(
+ TimberLoggerIntegration(
+ minEventLevel = SentryLevel.WARNING,
+ minBreadcrumbLevel = SentryLevel.INFO
+ )
+ )
+ }
+
+ val entryPoint = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ SentryInitializerEntryPoint::class.java
+ )
+ entryPoint.observer().start()
+
+ entryPoint.accountSentryHubBuilder().invoke(
+ sentryDsn = BuildConfig.ACCOUNT_SENTRY_DSN
+ ) { options ->
+ options.isEnableUncaughtExceptionHandler = false // MAILANDR-2602
+ }
+ }
+
+ override fun dependencies(): List>> = emptyList()
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface SentryInitializerEntryPoint {
+ fun accountSentryHubBuilder(): AccountSentryHubBuilder
+ fun observer(): SentryUserObserver
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/ThemeObserverInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/ThemeObserverInitializer.kt
new file mode 100644
index 0000000000..91cfdcf3dd
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/ThemeObserverInitializer.kt
@@ -0,0 +1,45 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.startup.Initializer
+import ch.protonmail.android.mailsettings.presentation.settings.theme.ThemeObserver
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+
+class ThemeObserverInitializer : Initializer {
+
+ override fun create(context: Context) {
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ ThemeObserverInitializerEntryPoint::class.java
+ ).themeObserver().start()
+ }
+
+ override fun dependencies(): List?>> = emptyList()
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface ThemeObserverInitializerEntryPoint {
+ fun themeObserver(): ThemeObserver
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/WorkManagerInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/WorkManagerInitializer.kt
new file mode 100644
index 0000000000..0315bed67f
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/WorkManagerInitializer.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.initializer
+
+import android.content.Context
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.startup.Initializer
+import androidx.work.Configuration
+import androidx.work.WorkManager
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+
+class WorkManagerInitializer : Initializer {
+
+ override fun create(context: Context): WorkManager {
+ val workerFactory = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ WorkManagerInitializerEntryPoint::class.java
+ ).hiltWorkerFactory()
+ val config = Configuration.Builder()
+ .setWorkerFactory(workerFactory)
+ .build()
+ WorkManager.initialize(context, config)
+ return WorkManager.getInstance(context)
+ }
+
+ override fun dependencies(): List?>> = emptyList()
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface WorkManagerInitializerEntryPoint {
+ fun hiltWorkerFactory(): HiltWorkerFactory
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/featureflag/RefreshRatingBoosterFeatureFlags.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/featureflag/RefreshRatingBoosterFeatureFlags.kt
new file mode 100644
index 0000000000..87bb5e0f30
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/featureflag/RefreshRatingBoosterFeatureFlags.kt
@@ -0,0 +1,55 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer.featureflag
+
+import ch.protonmail.android.mailcommon.domain.MailFeatureId
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.domain.entity.UserId
+import me.proton.core.featureflag.domain.FeatureFlagManager
+import me.proton.core.featureflag.domain.entity.FeatureFlag
+import me.proton.core.util.kotlin.CoroutineScopeProvider
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class RefreshRatingBoosterFeatureFlags @Inject constructor(
+ private val accountManager: AccountManager,
+ private val scopeProvider: CoroutineScopeProvider,
+ private val featureFlagManager: FeatureFlagManager
+) {
+
+ operator fun invoke() {
+ scopeProvider.GlobalIOSupervisedScope.launch {
+ accountManager.getAccounts().first().forEach { account ->
+ refreshRatingBoosterFeatureFlag(account.userId)
+ }
+ }
+ }
+
+ private suspend fun refreshRatingBoosterFeatureFlag(userId: UserId) {
+ featureFlagManager.getOrDefault(
+ userId = userId,
+ featureId = MailFeatureId.RatingBooster.id,
+ default = FeatureFlag.default(MailFeatureId.RatingBooster.id.id, false),
+ refresh = true
+ )
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/outbox/OutboxObserver.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/outbox/OutboxObserver.kt
new file mode 100644
index 0000000000..a7b195da0a
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/outbox/OutboxObserver.kt
@@ -0,0 +1,61 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer.outbox
+
+import ch.protonmail.android.mailcomposer.domain.usecase.DraftUploadTracker
+import ch.protonmail.android.mailmessage.data.usecase.DeleteSentMessagesFromOutbox
+import ch.protonmail.android.mailmessage.domain.model.DraftSyncState
+import ch.protonmail.android.mailmessage.domain.repository.OutboxRepository
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.util.kotlin.CoroutineScopeProvider
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class OutboxObserver @Inject constructor(
+ private val scopeProvider: CoroutineScopeProvider,
+ private val accountManager: AccountManager,
+ private val outboxRepository: OutboxRepository,
+ private val deleteSentMessagesFromOutbox: DeleteSentMessagesFromOutbox,
+ private val draftUploadTracker: DraftUploadTracker
+) {
+
+ fun start() = accountManager.getPrimaryUserId()
+ .filterNotNull()
+ .flatMapLatest { userId ->
+ outboxRepository.observeAll(userId)
+ }
+ .onEach { outboxDraftStates ->
+
+ val sentItems = outboxDraftStates.filter { draftState -> draftState.state == DraftSyncState.Sent }
+ if (sentItems.isNotEmpty()) {
+ draftUploadTracker.notifySentMessages(sentItems.map { it.messageId }.toSet())
+
+ deleteSentMessagesFromOutbox(
+ sentItems.first().userId,
+ sentItems
+ )
+ }
+ }
+ .launchIn(scopeProvider.GlobalIOSupervisedScope)
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/strictmode/StrictModeHackArrayList.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/strictmode/StrictModeHackArrayList.kt
new file mode 100644
index 0000000000..994f594aaa
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/strictmode/StrictModeHackArrayList.kt
@@ -0,0 +1,64 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer.strictmode
+
+import timber.log.Timber
+import java.lang.reflect.Method
+
+/**
+ * Special array list that skip additions for matching ViolationInfo instances as per
+ * hack described in https://atscaleconference.com/videos/eliminating-long-tail-jank-with-strictmode/
+ */
+class StrictModeHackArrayList : ArrayList() {
+
+ private val whitelistedViolations = listOf(
+ // Violations observed only in Firebase tests
+ "android.graphics.HwTypefaceUtil.getMultiWeightHwFamily",
+ "android.graphics.HwTypefaceUtil.updateFont",
+ // AppLanguageRepository reading locale from file through
+ // AppCompatDelegate (due to `autoStoreLocales` manifest metadata)
+ "androidx.appcompat.app.AppLocalesStorageHelper.readLocales",
+ // Firebase tests initialization
+ "androidx.test.runner.MonitoringInstrumentation.specifyDexMakerCacheProperty",
+ // Reading from file
+ "ch.protonmail.android.initializer.SentryInitializer.create",
+ // Reading from SharedPreferences
+ "me.proton.core.util.android.sharedpreferences.ExtensionsKt.nullableGet"
+ )
+
+ override fun add(element: Any): Boolean {
+ val hasDeclaredMethod = element.javaClass.declaredMethods.any { it.name == "getStackTrace" }
+ if (!hasDeclaredMethod) {
+ // call super to continue with standard violation reporting
+ return super.add(element)
+ }
+
+ val crashInfoMethod: Method = element.javaClass.getDeclaredMethod("getStackTrace")
+ crashInfoMethod.invoke(element)?.let { crashInfoStackTrace ->
+ for (whitelistedStacktraceCall in whitelistedViolations) {
+ if (crashInfoStackTrace.toString().contains(whitelistedStacktraceCall)) {
+ Timber.d("Skipping whitelisted StrictMode violation: $whitelistedStacktraceCall")
+ return false
+ }
+ }
+ }
+ // call super to continue with standard violation reporting
+ return super.add(element)
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/strictmode/StrictModeInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/strictmode/StrictModeInitializer.kt
new file mode 100644
index 0000000000..01c09a0dea
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/initializer/strictmode/StrictModeInitializer.kt
@@ -0,0 +1,71 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.initializer.strictmode
+
+import android.content.Context
+import android.os.StrictMode
+import androidx.startup.Initializer
+import ch.protonmail.android.BuildConfig
+import java.lang.reflect.Field
+import java.lang.reflect.Modifier
+import me.proton.core.util.android.strictmode.detectCommon
+
+class StrictModeInitializer : Initializer {
+
+ override fun create(context: Context) {
+ if (BuildConfig.DEBUG) {
+ enableStrictMode()
+ }
+ }
+
+ override fun dependencies(): List>> = emptyList()
+
+ private fun enableStrictMode() {
+ val threadPolicyBuilder = StrictMode.ThreadPolicy.Builder()
+ .detectAll()
+ .penaltyFlashScreen()
+ .penaltyLog()
+ val vmPolicyBuilder = StrictMode.VmPolicy.Builder()
+ .detectCommon()
+ .penaltyLog()
+
+ StrictMode.setThreadPolicy(threadPolicyBuilder.build())
+ StrictMode.setVmPolicy(vmPolicyBuilder.build())
+ ignoreWhitelistedWarnings()
+ }
+
+ private fun ignoreWhitelistedWarnings() {
+ // Source: https://atscaleconference.com/videos/eliminating-long-tail-jank-with-strictmode/
+ // On API levels above N, we can use reflection to read the violationsBeingTimed field of strict
+ // to get notifications about reported violations
+ val field = StrictMode::class.java.getDeclaredField("violationsBeingTimed")
+ field.isAccessible = true // Suppress Java language access checking
+ // Remove "final" modifier
+ val modifiersField = Field::class.java.getDeclaredField("accessFlags")
+ modifiersField.isAccessible = true
+ modifiersField.setInt(field, field.modifiers and Modifier.FINAL.inv())
+ // Override the field with a custom ArrayList, which is able to skip whitelisted violations
+ field.set(
+ null,
+ object : ThreadLocal>() {
+ override fun get(): ArrayList = StrictModeHackArrayList()
+ }
+ )
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/logging/LogsFileHandlerLifecycleObserver.kt b/app/src/main/kotlin/ch/protonmail/android/logging/LogsFileHandlerLifecycleObserver.kt
new file mode 100644
index 0000000000..9b0de7a0e5
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/logging/LogsFileHandlerLifecycleObserver.kt
@@ -0,0 +1,48 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.logging
+
+import android.content.Context
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import ch.protonmail.android.initializer.LoggerInitializer
+import ch.protonmail.android.mailbugreport.domain.LogsFileHandler
+import dagger.hilt.android.EntryPointAccessors
+
+/**
+ * A [LifecycleObserver] used to clean up the [LogsFileHandler] instance.
+ */
+internal class LogsFileHandlerLifecycleObserver(
+ context: Context
+) : DefaultLifecycleObserver {
+
+ private val logsFileHandler: LogsFileHandler
+
+ init {
+ val entryPoint = EntryPointAccessors.fromApplication(
+ context, LoggerInitializer.LoggerInitializerEntryPoint::class.java
+ )
+ logsFileHandler = entryPoint.logsFileHandlerProvider()
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ super.onDestroy(owner)
+ logsFileHandler.close()
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/logging/SentryUserObserver.kt b/app/src/main/kotlin/ch/protonmail/android/logging/SentryUserObserver.kt
new file mode 100644
index 0000000000..efbb919a33
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/logging/SentryUserObserver.kt
@@ -0,0 +1,43 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.logging
+
+import java.util.UUID
+import io.sentry.Sentry
+import io.sentry.protocol.User
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.util.kotlin.CoroutineScopeProvider
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SentryUserObserver @Inject constructor(
+ private val scopeProvider: CoroutineScopeProvider,
+ internal val accountManager: AccountManager
+) {
+
+ fun start() = accountManager.getPrimaryUserId()
+ .map { userId ->
+ val user = User().apply { id = userId?.id ?: UUID.randomUUID().toString() }
+ Sentry.setUser(user)
+ }
+ .launchIn(scopeProvider.GlobalDefaultSupervisedScope)
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/Home.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/Home.kt
new file mode 100644
index 0000000000..84fa2b383b
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/Home.kt
@@ -0,0 +1,704 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.Scaffold
+import androidx.compose.material.SnackbarDuration
+import androidx.compose.material.SnackbarResult
+import androidx.compose.material.rememberScaffoldState
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+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
+import androidx.navigation.compose.rememberNavController
+import ch.protonmail.android.LockScreenActivity
+import ch.protonmail.android.MainActivity
+import ch.protonmail.android.R
+import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect
+import ch.protonmail.android.mailcommon.presentation.Effect
+import ch.protonmail.android.mailcommon.presentation.compose.UndoableOperationSnackbar
+import ch.protonmail.android.mailcommon.presentation.extension.navigateBack
+import ch.protonmail.android.mailcommon.presentation.model.ActionResult
+import ch.protonmail.android.mailcommon.presentation.ui.CommonTestTags
+import ch.protonmail.android.mailcomposer.domain.model.MessageSendingStatus
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetail
+import ch.protonmail.android.maildetail.presentation.ui.MessageDetail
+import ch.protonmail.android.mailmessage.domain.model.DraftAction
+import ch.protonmail.android.mailmessage.domain.model.MessageId
+import ch.protonmail.android.mailnotifications.domain.model.telemetry.NotificationPermissionTelemetryEventType
+import ch.protonmail.android.mailnotifications.presentation.EnablePushNotificationsDialog
+import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogState
+import ch.protonmail.android.mailsidebar.presentation.Sidebar
+import ch.protonmail.android.mailupselling.presentation.ui.screen.UpsellingScreen
+import ch.protonmail.android.navigation.listener.withDestinationChangedObservableEffect
+import ch.protonmail.android.navigation.model.Destination.Dialog
+import ch.protonmail.android.navigation.model.Destination.Screen
+import ch.protonmail.android.navigation.route.addAccountSettings
+import ch.protonmail.android.navigation.route.addAlternativeRoutingSetting
+import ch.protonmail.android.navigation.route.addAutoDeleteSettings
+import ch.protonmail.android.navigation.route.addAutoLockPinScreen
+import ch.protonmail.android.navigation.route.addAutoLockSettings
+import ch.protonmail.android.navigation.route.addCombinedContactsSetting
+import ch.protonmail.android.navigation.route.addComposer
+import ch.protonmail.android.navigation.route.addContactDetails
+import ch.protonmail.android.navigation.route.addContactForm
+import ch.protonmail.android.navigation.route.addContactGroupDetails
+import ch.protonmail.android.navigation.route.addContactGroupForm
+import ch.protonmail.android.navigation.route.addContactSearch
+import ch.protonmail.android.navigation.route.addContacts
+import ch.protonmail.android.navigation.route.addConversationDetail
+import ch.protonmail.android.navigation.route.addConversationModeSettings
+import ch.protonmail.android.navigation.route.addCustomizeToolbar
+import ch.protonmail.android.navigation.route.addDeepLinkHandler
+import ch.protonmail.android.navigation.route.addDefaultEmailSettings
+import ch.protonmail.android.navigation.route.addDisplayNameSettings
+import ch.protonmail.android.navigation.route.addEditSwipeActionsSettings
+import ch.protonmail.android.navigation.route.addExportLogsSettings
+import ch.protonmail.android.navigation.route.addFolderForm
+import ch.protonmail.android.navigation.route.addFolderList
+import ch.protonmail.android.navigation.route.addLabelForm
+import ch.protonmail.android.navigation.route.addLabelList
+import ch.protonmail.android.navigation.route.addLanguageSettings
+import ch.protonmail.android.navigation.route.addMailbox
+import ch.protonmail.android.navigation.route.addManageMembers
+import ch.protonmail.android.navigation.route.addEntireMessageBody
+import ch.protonmail.android.navigation.route.addMessageDetail
+import ch.protonmail.android.navigation.route.addNotificationsSettings
+import ch.protonmail.android.navigation.route.addParentFolderList
+import ch.protonmail.android.navigation.route.addPrivacySettings
+import ch.protonmail.android.navigation.route.addRemoveAccountDialog
+import ch.protonmail.android.navigation.route.addSetMessagePassword
+import ch.protonmail.android.navigation.route.addSettings
+import ch.protonmail.android.navigation.route.addSignOutAccountDialog
+import ch.protonmail.android.navigation.route.addSwipeActionsSettings
+import ch.protonmail.android.navigation.route.addThemeSettings
+import ch.protonmail.android.navigation.route.addUpsellingRoutes
+import ch.protonmail.android.uicomponents.snackbar.DismissableSnackbarHost
+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.theme.ProtonTheme
+import me.proton.core.network.domain.NetworkStatus
+
+@Composable
+@Suppress("ComplexMethod")
+fun Home(
+ activityActions: MainActivity.Actions,
+ launcherActions: Launcher.Actions,
+ viewModel: HomeViewModel = hiltViewModel()
+) {
+ val navController = rememberNavController()
+ .withSentryObservableEffect()
+ .withDestinationChangedObservableEffect()
+ var isNavHostReady by remember { mutableStateOf(false) }
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val currentDestinationRoute = navBackStackEntry?.destination?.route
+
+ val scaffoldState = rememberScaffoldState()
+ val snackbarHostSuccessState = remember { ProtonSnackbarHostState(defaultType = ProtonSnackbarType.SUCCESS) }
+ val snackbarHostWarningState = remember { ProtonSnackbarHostState(defaultType = ProtonSnackbarType.WARNING) }
+ val snackbarHostNormState = remember { ProtonSnackbarHostState(defaultType = ProtonSnackbarType.NORM) }
+ val snackbarHostErrorState = remember { ProtonSnackbarHostState(defaultType = ProtonSnackbarType.ERROR) }
+ val scope = rememberCoroutineScope()
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ val offlineSnackbarMessage = stringResource(id = R.string.you_are_offline)
+ fun showOfflineSnackbar() = scope.launch {
+ snackbarHostWarningState.showSnackbar(
+ message = offlineSnackbarMessage,
+ type = ProtonSnackbarType.WARNING
+ )
+ }
+
+ ConsumableLaunchedEffect(state.networkStatusEffect) {
+ if (it == NetworkStatus.Disconnected) {
+ showOfflineSnackbar()
+ }
+ }
+
+ // Ensure that the navigation graph is defined and the composable routes attached to it.
+ LaunchedEffect(state.navigateToEffect, isNavHostReady) {
+ if (!isNavHostReady) return@LaunchedEffect
+
+ state.navigateToEffect.consume()?.let {
+ viewModel.navigateTo(navController, it)
+ }
+ }
+
+ val featureMissingSnackbarMessage = stringResource(id = R.string.feature_coming_soon)
+ fun showFeatureMissingSnackbar() = scope.launch {
+ snackbarHostNormState.showSnackbar(
+ message = featureMissingSnackbarMessage,
+ type = ProtonSnackbarType.NORM
+ )
+ }
+
+ fun showErrorSnackbar(text: String) = scope.launch {
+ snackbarHostErrorState.showSnackbar(
+ message = text,
+ type = ProtonSnackbarType.ERROR
+ )
+ }
+
+ fun showNormalSnackbar(text: String) = scope.launch {
+ snackbarHostErrorState.showSnackbar(
+ message = text,
+ type = ProtonSnackbarType.NORM
+ )
+ }
+
+ val draftSavedText = stringResource(id = R.string.mailbox_draft_saved)
+ val draftSavedDiscardText = stringResource(id = R.string.mailbox_draft_discard)
+ fun showDraftSavedSnackbar(messageId: MessageId) = scope.launch {
+ val result = snackbarHostSuccessState.showSnackbar(
+ message = draftSavedText,
+ type = ProtonSnackbarType.SUCCESS,
+ actionLabel = draftSavedDiscardText
+
+ )
+ when (result) {
+ SnackbarResult.ActionPerformed -> viewModel.discardDraft(messageId)
+ SnackbarResult.Dismissed -> Unit
+ }
+ }
+
+ val sendingMessageText = stringResource(id = R.string.mailbox_message_sending)
+ fun showMessageSendingSnackbar() = scope.launch {
+ snackbarHostNormState.showSnackbar(message = sendingMessageText, type = ProtonSnackbarType.NORM)
+ }
+
+ val sendingMessageOfflineText = stringResource(id = R.string.mailbox_message_sending_offline)
+ fun showMessageSendingOfflineSnackbar() = scope.launch {
+ snackbarHostNormState.showSnackbar(message = sendingMessageOfflineText, type = ProtonSnackbarType.NORM)
+ }
+
+ val successSendingMessageText = stringResource(id = R.string.mailbox_message_sending_success)
+ fun showSuccessSendingMessageSnackbar() = scope.launch {
+ snackbarHostSuccessState.showSnackbar(message = successSendingMessageText, type = ProtonSnackbarType.SUCCESS)
+ }
+
+ val errorSendingMessageText = stringResource(id = R.string.mailbox_message_sending_error)
+ val errorSendingMessageActionText = stringResource(id = R.string.mailbox_message_sending_error_action)
+ fun showErrorSendingMessageSnackbar() = scope.launch {
+ val shouldShowAction = viewModel.shouldNavigateToDraftsOnSendingFailure(navController.currentDestination)
+ val result = snackbarHostErrorState.showSnackbar(
+ type = ProtonSnackbarType.ERROR,
+ message = errorSendingMessageText,
+ actionLabel = if (shouldShowAction) errorSendingMessageActionText else null,
+ duration = if (shouldShowAction) SnackbarDuration.Long else SnackbarDuration.Short
+ )
+ when (result) {
+ SnackbarResult.ActionPerformed -> viewModel.navigateToDrafts(navController)
+ SnackbarResult.Dismissed -> Unit
+ }
+ }
+
+ val errorUploadAttachmentText = stringResource(id = R.string.mailbox_attachment_uploading_error)
+ fun showErrorUploadAttachmentSnackbar() = scope.launch {
+ snackbarHostErrorState.showSnackbar(message = errorUploadAttachmentText, type = ProtonSnackbarType.ERROR)
+ }
+
+ val labelSavedText = stringResource(id = R.string.label_saved)
+ fun showLabelSavedSnackbar() = scope.launch {
+ snackbarHostSuccessState.showSnackbar(message = labelSavedText, type = ProtonSnackbarType.SUCCESS)
+ }
+
+ val labelDeletedText = stringResource(id = R.string.label_deleted)
+ fun showLabelDeletedSnackbar() = scope.launch {
+ snackbarHostSuccessState.showSnackbar(message = labelDeletedText, type = ProtonSnackbarType.SUCCESS)
+ }
+
+ fun showUpsellingSnackbar(message: String) = scope.launch {
+ snackbarHostNormState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.NORM
+ )
+ }
+
+ fun showUpsellingErrorSnackbar(message: String) = scope.launch {
+ snackbarHostErrorState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.ERROR
+ )
+ }
+
+ val labelListErrorLoadingText = stringResource(id = R.string.label_list_loading_error)
+ fun showLabelListErrorLoadingSnackbar() = scope.launch {
+ snackbarHostErrorState.showSnackbar(message = labelListErrorLoadingText, type = ProtonSnackbarType.ERROR)
+ }
+
+ val undoActionEffect = remember { mutableStateOf(Effect.empty()) }
+ UndoableOperationSnackbar(snackbarHostState = snackbarHostNormState, actionEffect = undoActionEffect.value)
+ fun showUndoableOperationSnackbar(actionResult: ActionResult) = scope.launch {
+ undoActionEffect.value = Effect.of(actionResult)
+ }
+
+ ConsumableLaunchedEffect(state.messageSendingStatusEffect) { sendingStatus ->
+ when (sendingStatus) {
+ is MessageSendingStatus.MessageSent -> showSuccessSendingMessageSnackbar()
+ is MessageSendingStatus.SendMessageError -> showErrorSendingMessageSnackbar()
+ is MessageSendingStatus.UploadAttachmentsError -> showErrorUploadAttachmentSnackbar()
+ is MessageSendingStatus.None -> {}
+ }
+ }
+
+ when (val notificationPermissionDialogState = state.notificationPermissionDialogState) {
+ is NotificationPermissionDialogState.Hidden -> Unit
+ is NotificationPermissionDialogState.Shown -> {
+ EnablePushNotificationsDialog(
+ state = notificationPermissionDialogState,
+ onEnable = {
+ launcherActions.onRequestNotificationPermission()
+ viewModel.closeNotificationPermissionDialog()
+ viewModel.trackTelemetryEvent(
+ NotificationPermissionTelemetryEventType.NotificationPermissionDialogEnable(
+ notificationPermissionDialogState.type
+ )
+ )
+ },
+ onDismiss = {
+ viewModel.closeNotificationPermissionDialog()
+ viewModel.trackTelemetryEvent(
+ NotificationPermissionTelemetryEventType.NotificationPermissionDialogDismiss(
+ notificationPermissionDialogState.type
+ )
+ )
+ }
+ )
+ }
+ }
+
+ Scaffold(
+ scaffoldState = scaffoldState,
+ drawerShape = RectangleShape,
+ drawerScrimColor = ProtonTheme.colors.blenderNorm,
+ drawerContent = {
+ Sidebar(
+ drawerState = scaffoldState.drawerState,
+ navigationActions = buildSidebarActions(navController, launcherActions)
+ )
+ },
+ drawerGesturesEnabled = currentDestinationRoute == Screen.Mailbox.route,
+ snackbarHost = {
+ DismissableSnackbarHost(
+ modifier = Modifier.testTag(CommonTestTags.SnackbarHostSuccess),
+ protonSnackbarHostState = snackbarHostSuccessState
+ )
+ DismissableSnackbarHost(
+ modifier = Modifier.testTag(CommonTestTags.SnackbarHostWarning),
+ protonSnackbarHostState = snackbarHostWarningState
+ )
+ DismissableSnackbarHost(
+ modifier = Modifier.testTag(CommonTestTags.SnackbarHostNormal),
+ protonSnackbarHostState = snackbarHostNormState
+ )
+ DismissableSnackbarHost(
+ modifier = Modifier.testTag(CommonTestTags.SnackbarHostError),
+ protonSnackbarHostState = snackbarHostErrorState
+ )
+ }
+ ) { contentPadding ->
+ Box(
+ Modifier.padding(contentPadding)
+ ) {
+ NavHost(
+ modifier = Modifier.fillMaxSize(),
+ navController = navController,
+ startDestination = Screen.Mailbox.route
+ ) {
+ // home
+ addConversationDetail(
+ actions = ConversationDetail.Actions(
+ onExit = { notifyUserMessage ->
+ navController.navigateBack()
+ notifyUserMessage?.let { showUndoableOperationSnackbar(it) }
+ viewModel.recordViewOfMailboxScreen()
+ },
+ openMessageBodyLink = activityActions.openInActivityInNewTask,
+ openAttachment = activityActions.openIntentChooser,
+ handleProtonCalendarRequest = activityActions.openProtonCalendarIntentValues,
+ onAddLabel = { navController.navigate(Screen.LabelList.route) },
+ onAddFolder = { navController.navigate(Screen.FolderList.route) },
+ showFeatureMissingSnackbar = { showFeatureMissingSnackbar() },
+ onReply = { navController.navigate(Screen.MessageActionComposer(DraftAction.Reply(it))) },
+ onReplyAll = { navController.navigate(Screen.MessageActionComposer(DraftAction.ReplyAll(it))) },
+ onForward = { navController.navigate(Screen.MessageActionComposer(DraftAction.Forward(it))) },
+ onViewContactDetails = { navController.navigate(Screen.ContactDetails(it)) },
+ onAddContact = { basicContactInfo ->
+ navController.navigate(Screen.AddContact(basicContactInfo))
+ },
+ onComposeNewMessage = {
+ navController.navigate(
+ Screen.MessageActionComposer(
+ DraftAction.ComposeToAddresses(
+ listOf(it)
+ )
+ )
+ )
+ },
+ navigateToCustomizeToolbar = {
+ navController.navigate(Screen.CustomizeToolbar.route)
+ },
+ openComposerForDraftMessage = { navController.navigate(Screen.EditDraftComposer(it)) },
+ showSnackbar = { message ->
+ scope.launch {
+ snackbarHostNormState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.NORM
+ )
+ }
+ },
+ recordMailboxScreenView = { viewModel.recordViewOfMailboxScreen() },
+ onViewEntireMessageClicked =
+ { messageId, shouldShowEmbeddedImages, shouldShowRemoteContent, viewModePreference ->
+ navController.navigate(
+ Screen.EntireMessageBody(
+ messageId, shouldShowEmbeddedImages, shouldShowRemoteContent, viewModePreference
+ )
+ )
+ }
+ )
+ )
+ addMailbox(
+ navController,
+ drawerState = scaffoldState.drawerState,
+ showOfflineSnackbar = { showOfflineSnackbar() },
+ showNormalSnackbar = { showNormalSnackbar(it) },
+ showErrorSnackbar = { showErrorSnackbar(it) },
+ onRequestNotificationPermission = launcherActions.onRequestNotificationPermission
+ )
+ addMessageDetail(
+ actions = MessageDetail.Actions(
+ onExit = { notifyUserMessage ->
+ navController.navigateBack()
+ notifyUserMessage?.let { showUndoableOperationSnackbar(it) }
+ viewModel.recordViewOfMailboxScreen()
+ },
+ openMessageBodyLink = activityActions.openInActivityInNewTask,
+ openAttachment = activityActions.openIntentChooser,
+ handleProtonCalendarRequest = activityActions.openProtonCalendarIntentValues,
+ onAddLabel = { navController.navigate(Screen.LabelList.route) },
+ onAddFolder = { navController.navigate(Screen.FolderList.route) },
+ showFeatureMissingSnackbar = { showFeatureMissingSnackbar() },
+ onReply = { navController.navigate(Screen.MessageActionComposer(DraftAction.Reply(it))) },
+ onReplyAll = { navController.navigate(Screen.MessageActionComposer(DraftAction.ReplyAll(it))) },
+ onForward = { navController.navigate(Screen.MessageActionComposer(DraftAction.Forward(it))) },
+ onViewContactDetails = { navController.navigate(Screen.ContactDetails(it)) },
+ onAddContact = { basicContactInfo ->
+ navController.navigate(Screen.AddContact(basicContactInfo))
+ },
+ onComposeNewMessage = {
+ navController.navigate(
+ Screen.MessageActionComposer(
+ DraftAction.ComposeToAddresses(
+ listOf(it)
+ )
+ )
+ )
+ },
+ showSnackbar = { message ->
+ scope.launch {
+ snackbarHostNormState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.NORM
+ )
+ }
+ },
+ navigateToCustomizeToolbar = {
+ navController.navigate(Screen.CustomizeToolbar.route)
+ },
+ recordMailboxScreenView = { viewModel.recordViewOfMailboxScreen() },
+ onViewEntireMessageClicked =
+ { messageId, shouldShowEmbeddedImages, shouldShowRemoteContent, viewModePreference ->
+ navController.navigate(
+ Screen.EntireMessageBody(
+ messageId, shouldShowEmbeddedImages, shouldShowRemoteContent, viewModePreference
+ )
+ )
+ }
+ )
+ )
+ addEntireMessageBody(
+ navController,
+ onOpenMessageBodyLink = activityActions.openInActivityInNewTask
+ )
+ addComposer(
+ navController,
+ activityActions,
+ showDraftSavedSnackbar = { showDraftSavedSnackbar(it) },
+ showMessageSendingSnackbar = { showMessageSendingSnackbar() },
+ showMessageSendingOfflineSnackbar = { showMessageSendingOfflineSnackbar() },
+ showComposerV2 = viewModel.isComposerV2Enabled
+ )
+
+ addSetMessagePassword(navController)
+ addSignOutAccountDialog(navController)
+ addRemoveAccountDialog(navController)
+ addSettings(navController)
+ addLabelList(
+ navController,
+ showLabelListErrorLoadingSnackbar = { showLabelListErrorLoadingSnackbar() }
+ )
+ addLabelForm(
+ navController,
+ showLabelSavedSnackbar = { showLabelSavedSnackbar() },
+ showLabelDeletedSnackbar = { showLabelDeletedSnackbar() },
+ showUpsellingSnackbar = { showUpsellingSnackbar(it) },
+ showUpsellingErrorSnackbar = { showUpsellingErrorSnackbar(it) }
+ )
+ addFolderList(
+ navController,
+ showErrorSnackbar = { message ->
+ scope.launch {
+ snackbarHostErrorState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.ERROR
+ )
+ }
+ }
+ )
+ addFolderForm(
+ navController,
+ showSuccessSnackbar = { message ->
+ scope.launch {
+ snackbarHostSuccessState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.SUCCESS
+ )
+ }
+ },
+ showErrorSnackbar = { message ->
+ scope.launch {
+ snackbarHostErrorState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.ERROR
+ )
+ }
+ },
+ showNormSnackbar = { message ->
+ scope.launch {
+ snackbarHostNormState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.NORM
+ )
+ }
+ }
+ )
+ addParentFolderList(
+ navController,
+ showErrorSnackbar = { message ->
+ scope.launch {
+ snackbarHostErrorState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.ERROR
+ )
+ }
+ }
+ )
+ // settings
+ addAccountSettings(navController, launcherActions, activityActions)
+ addContacts(
+ navController,
+ showErrorSnackbar = { message ->
+ scope.launch {
+ snackbarHostErrorState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.ERROR
+ )
+ }
+ },
+ showNormalSnackbar = {
+ showNormalSnackbar(it)
+ },
+ showFeatureMissingSnackbar = {
+ showFeatureMissingSnackbar()
+ }
+ )
+ addContactDetails(
+ navController,
+ showSuccessSnackbar = { message ->
+ scope.launch {
+ snackbarHostSuccessState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.SUCCESS
+ )
+ }
+ },
+ showErrorSnackbar = { message ->
+ scope.launch {
+ snackbarHostErrorState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.ERROR
+ )
+ }
+ },
+ showFeatureMissingSnackbar = {
+ showFeatureMissingSnackbar()
+ }
+ )
+ addContactForm(
+ navController,
+ showSuccessSnackbar = { message ->
+ scope.launch {
+ snackbarHostSuccessState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.SUCCESS
+ )
+ }
+ },
+ showErrorSnackbar = { message ->
+ scope.launch {
+ snackbarHostErrorState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.ERROR
+ )
+ }
+ }
+ )
+ addContactGroupDetails(
+ navController,
+ showErrorSnackbar = { message ->
+ scope.launch {
+ snackbarHostErrorState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.ERROR
+ )
+ }
+ },
+ showNormSnackbar = { message ->
+ scope.launch {
+ snackbarHostNormState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.NORM
+ )
+ }
+ }
+ )
+ addContactGroupForm(
+ navController,
+ showSuccessSnackbar = { message ->
+ scope.launch {
+ snackbarHostSuccessState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.SUCCESS
+ )
+ }
+ },
+ showErrorSnackbar = { message ->
+ scope.launch {
+ snackbarHostErrorState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.ERROR
+ )
+ }
+ },
+ showNormSnackbar = { message ->
+ scope.launch {
+ snackbarHostNormState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.NORM
+ )
+ }
+ }
+ )
+ addManageMembers(
+ navController,
+ showErrorSnackbar = { message ->
+ scope.launch {
+ snackbarHostErrorState.showSnackbar(
+ message = message,
+ type = ProtonSnackbarType.ERROR
+ )
+ }
+ }
+ )
+ addContactSearch(
+ navController
+ )
+ addAlternativeRoutingSetting(navController)
+ addCombinedContactsSetting(navController)
+ addConversationModeSettings(navController)
+ addAutoDeleteSettings(navController)
+ addDefaultEmailSettings(navController)
+ addDisplayNameSettings(navController)
+ addEditSwipeActionsSettings(navController)
+ addLanguageSettings(navController)
+ addCustomizeToolbar(navController)
+ addPrivacySettings(navController)
+ addAutoLockSettings(navController)
+ addAutoLockPinScreen(
+ onBack = { navController.navigateBack() },
+ onShowSuccessSnackbar = {
+ scope.launch {
+ snackbarHostSuccessState.showSnackbar(message = it, type = ProtonSnackbarType.SUCCESS)
+ }
+ },
+ activityActions = LockScreenActivity.Actions.Empty
+ )
+ addSwipeActionsSettings(navController)
+ addThemeSettings(navController)
+ addNotificationsSettings(navController)
+ addExportLogsSettings(navController)
+ addDeepLinkHandler(navController)
+ addUpsellingRoutes(
+ UpsellingScreen.Actions.Empty.copy(
+ onDismiss = { navController.navigateBack() },
+ onUpgrade = { message -> scope.launch { showNormalSnackbar(message) } },
+ onError = { message -> scope.launch { showErrorSnackbar(message) } }
+ )
+ )
+
+ isNavHostReady = true
+ }
+ }
+ }
+}
+
+private fun buildSidebarActions(navController: NavHostController, launcherActions: Launcher.Actions) =
+ Sidebar.NavigationActions(
+ onSignIn = launcherActions.onSignIn,
+ onSignOut = { navController.navigate(Dialog.SignOut(it)) },
+ onUpsell = { navController.navigate(Screen.Upselling.StandaloneNavbar.route) },
+ onRemoveAccount = { navController.navigate(Dialog.RemoveAccount(it)) },
+ onSwitchAccount = launcherActions.onSwitchAccount,
+ onSettings = { navController.navigate(Screen.Settings.route) },
+ onLabelList = { navController.navigate(Screen.LabelList.route) },
+ onFolderList = { navController.navigate(Screen.FolderList.route) },
+ onLabelAdd = { navController.navigate(Screen.CreateLabel.route) },
+ onFolderAdd = { navController.navigate(Screen.CreateFolder.route) },
+ onSubscription = launcherActions.onSubscription,
+ onContacts = { navController.navigate(Screen.Contacts.route) },
+ onReportBug = launcherActions.onReportBug
+ )
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/HomeViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/HomeViewModel.kt
new file mode 100644
index 0000000000..a3abb9cf45
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/HomeViewModel.kt
@@ -0,0 +1,248 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation
+
+import android.content.Intent
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import ch.protonmail.android.mailcommon.data.file.getShareInfo
+import ch.protonmail.android.mailcommon.data.file.isStartedFromLauncher
+import ch.protonmail.android.mailcommon.domain.model.IntentShareInfo
+import ch.protonmail.android.mailcommon.domain.model.encode
+import ch.protonmail.android.mailcommon.domain.model.isNotEmpty
+import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUser
+import ch.protonmail.android.mailcommon.presentation.Effect
+import ch.protonmail.android.mailcomposer.domain.annotation.IsComposerV2Enabled
+import ch.protonmail.android.mailcomposer.domain.model.MessageSendingStatus
+import ch.protonmail.android.mailcomposer.domain.usecase.DiscardDraft
+import ch.protonmail.android.mailcomposer.domain.usecase.ObserveSendingMessagesStatus
+import ch.protonmail.android.mailcomposer.domain.usecase.ResetSendingMessagesStatus
+import ch.protonmail.android.maillabel.domain.SelectedMailLabelId
+import ch.protonmail.android.maillabel.domain.model.MailLabelId
+import ch.protonmail.android.mailmailbox.domain.usecase.RecordMailboxScreenView
+import ch.protonmail.android.mailmessage.domain.model.DraftAction
+import ch.protonmail.android.mailmessage.domain.model.MessageId
+import ch.protonmail.android.mailnotifications.domain.model.telemetry.NotificationPermissionTelemetryEventType
+import ch.protonmail.android.mailnotifications.domain.usecase.SavePermissionDialogTimestamp
+import ch.protonmail.android.mailnotifications.domain.usecase.SaveShouldStopShowingPermissionDialog
+import ch.protonmail.android.mailnotifications.domain.usecase.ShouldShowNotificationPermissionDialog
+import ch.protonmail.android.mailnotifications.domain.usecase.TrackNotificationPermissionTelemetryEvent
+import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogState
+import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogType
+import ch.protonmail.android.navigation.model.Destination
+import ch.protonmail.android.navigation.model.HomeState
+import ch.protonmail.android.navigation.share.ShareIntentObserver
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import me.proton.core.network.domain.NetworkManager
+import me.proton.core.network.domain.NetworkStatus
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltViewModel
+class HomeViewModel @Inject constructor(
+ private val networkManager: NetworkManager,
+ private val observeSendingMessagesStatus: ObserveSendingMessagesStatus,
+ private val recordMailboxScreenView: RecordMailboxScreenView,
+ private val resetSendingMessageStatus: ResetSendingMessagesStatus,
+ private val selectedMailLabelId: SelectedMailLabelId,
+ private val discardDraft: DiscardDraft,
+ private val shouldShowNotificationPermissionDialog: ShouldShowNotificationPermissionDialog,
+ private val savePermissionDialogTimestamp: SavePermissionDialogTimestamp,
+ private val saveShouldStopShowingPermissionDialog: SaveShouldStopShowingPermissionDialog,
+ private val trackNotificationPermissionTelemetryEvent: TrackNotificationPermissionTelemetryEvent,
+ @IsComposerV2Enabled val isComposerV2Enabled: Boolean,
+ observePrimaryUser: ObservePrimaryUser,
+ shareIntentObserver: ShareIntentObserver
+) : ViewModel() {
+
+ private val primaryUser = observePrimaryUser().filterNotNull()
+
+ private val mutableState = MutableStateFlow(HomeState.Initial)
+
+ val state: StateFlow = mutableState
+
+ init {
+ observeNetworkStatus().onEach { networkStatus ->
+ if (networkStatus == NetworkStatus.Disconnected) {
+ delay(NetworkStatusUpdateDelay)
+ emitNewStateFor(networkManager.networkStatus)
+ } else {
+ emitNewStateFor(networkStatus)
+ }
+ }.launchIn(viewModelScope)
+
+ primaryUser.flatMapLatest { user ->
+ observeSendingMessagesStatus(user.userId)
+ }.onEach {
+ emitNewStateFor(it)
+ resetSendingMessageStatus(primaryUser.first().userId)
+ }.launchIn(viewModelScope)
+
+ shareIntentObserver()
+ .onEach { intent ->
+ emitNewStateForIntent(intent)
+ }
+ .launchIn(viewModelScope)
+
+ showNotificationPermissionDialogIfNeeded(isMessageSent = false)
+ }
+
+ fun navigateTo(navController: NavController, route: String) {
+ navController.navigate(route = route)
+ }
+
+ /**
+ * Navigate to Drafts only when:
+ * - we are outside of Mailbox
+ * - we are in Mailbox but not in Drafts
+ */
+ fun shouldNavigateToDraftsOnSendingFailure(currentNavDestination: NavDestination?): Boolean =
+ currentNavDestination?.route != Destination.Screen.Mailbox.route ||
+ selectedMailLabelId.flow.value.labelId != MailLabelId.System.AllDrafts.labelId &&
+ selectedMailLabelId.flow.value.labelId != MailLabelId.System.Drafts.labelId
+
+ fun navigateToDrafts(navController: NavController) {
+ if (navController.currentDestination?.route != Destination.Screen.Mailbox.route) {
+ navController.popBackStack(Destination.Screen.Mailbox.route, inclusive = false)
+ }
+ selectedMailLabelId.set(MailLabelId.System.Drafts)
+ }
+
+ fun discardDraft(messageId: MessageId) {
+ viewModelScope.launch {
+ primaryUser.firstOrNull()?.let {
+ discardDraft(it.userId, messageId)
+ } ?: Timber.e("Primary user is not available!")
+ }
+ }
+
+ fun recordViewOfMailboxScreen() = recordMailboxScreenView()
+
+ fun closeNotificationPermissionDialog() {
+ mutableState.update { currentState ->
+ currentState.copy(
+ notificationPermissionDialogState = NotificationPermissionDialogState.Hidden
+ )
+ }
+ }
+
+ fun trackTelemetryEvent(eventType: NotificationPermissionTelemetryEventType) =
+ trackNotificationPermissionTelemetryEvent(eventType)
+
+ private fun emitNewStateFor(messageSendingStatus: MessageSendingStatus) {
+ if (messageSendingStatus == MessageSendingStatus.None) {
+ // Emitting a None status to UI would override the previously emitted effect and cause snack not to show
+ return
+ }
+
+ if (messageSendingStatus == MessageSendingStatus.MessageSent) {
+ showNotificationPermissionDialogIfNeeded(isMessageSent = true)
+ }
+
+ mutableState.update { currentState ->
+ currentState.copy(
+ messageSendingStatusEffect = Effect.of(messageSendingStatus)
+ )
+ }
+ }
+
+ private fun emitNewStateForIntent(intent: Intent) {
+ if (intent.isStartedFromLauncher()) {
+ mutableState.update { currentState ->
+ currentState.copy(startedFromLauncher = true)
+ }
+ } else if (!mutableState.value.startedFromLauncher) {
+ val intentShareInfo = intent.getShareInfo()
+ if (intentShareInfo.isNotEmpty()) {
+ emitNewStateForShareVia(intentShareInfo)
+ }
+ } else {
+ Timber.d("Share intent is not processed as this instance was started from launcher!")
+ }
+ }
+
+ private fun emitNewStateForShareVia(intentShareInfo: IntentShareInfo) {
+ mutableState.update { currentState ->
+ currentState.copy(
+ navigateToEffect = Effect.of(
+ Destination.Screen.ShareFileComposer(DraftAction.PrefillForShare(intentShareInfo.encode()))
+ )
+ )
+ }
+ }
+
+ private fun emitNewStateFor(networkStatus: NetworkStatus) {
+ mutableState.update { currentState ->
+ currentState.copy(networkStatusEffect = Effect.of(networkStatus))
+ }
+ }
+
+ private fun observeNetworkStatus() = networkManager.observe().distinctUntilChanged()
+
+ private fun showNotificationPermissionDialogIfNeeded(isMessageSent: Boolean) {
+ viewModelScope.launch {
+ if (!shouldShowNotificationPermissionDialog(System.currentTimeMillis(), isMessageSent)) return@launch
+
+ val notificationPermissionDialogType = if (isMessageSent) {
+ NotificationPermissionDialogType.PostSending
+ } else {
+ NotificationPermissionDialogType.PostOnboarding
+ }
+
+ mutableState.update { currentState ->
+ currentState.copy(
+ notificationPermissionDialogState = NotificationPermissionDialogState.Shown(
+ type = notificationPermissionDialogType
+ )
+ )
+ }
+
+ trackTelemetryEvent(
+ NotificationPermissionTelemetryEventType.NotificationPermissionDialogDisplayed(
+ notificationPermissionDialogType
+ )
+ )
+
+ if (isMessageSent) {
+ saveShouldStopShowingPermissionDialog()
+ } else {
+ savePermissionDialogTimestamp(System.currentTimeMillis())
+ }
+ }
+ }
+
+ companion object {
+
+ const val NetworkStatusUpdateDelay = 5000L
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/Launcher.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/Launcher.kt
new file mode 100644
index 0000000000..5cafe9f46f
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/Launcher.kt
@@ -0,0 +1,71 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import ch.protonmail.android.MainActivity
+import ch.protonmail.android.navigation.model.LauncherState
+import me.proton.core.compose.component.ProtonCenteredProgress
+import me.proton.core.domain.entity.UserId
+
+@Composable
+fun Launcher(activityActions: MainActivity.Actions, viewModel: LauncherViewModel = hiltViewModel()) {
+ val state by viewModel.state.collectAsState(LauncherState.Processing)
+
+ when (state) {
+ LauncherState.AccountNeeded -> viewModel.submit(LauncherViewModel.Action.AddAccount)
+ LauncherState.PrimaryExist -> LauncherRouter(
+ activityActions = activityActions,
+ launcherActions = Launcher.Actions(
+ onPasswordManagement = { viewModel.submit(LauncherViewModel.Action.OpenPasswordManagement) },
+ onRecoveryEmail = { viewModel.submit(LauncherViewModel.Action.OpenRecoveryEmail) },
+ onReportBug = { viewModel.submit(LauncherViewModel.Action.OpenReport) },
+ onSignIn = { viewModel.submit(LauncherViewModel.Action.SignIn(it)) },
+ onSubscription = { viewModel.submit(LauncherViewModel.Action.OpenSubscription) },
+ onSwitchAccount = { viewModel.submit(LauncherViewModel.Action.Switch(it)) },
+ onRequestNotificationPermission = {
+ viewModel.submit(LauncherViewModel.Action.RequestNotificationPermission)
+ }
+ )
+ )
+ LauncherState.Processing,
+ LauncherState.StepNeeded -> ProtonCenteredProgress(Modifier.fillMaxSize())
+ }
+}
+
+object Launcher {
+
+ /**
+ * A set of actions that can be executed in the scope of Core's Orchestrators
+ */
+ data class Actions(
+ val onSignIn: (UserId?) -> Unit,
+ val onSwitchAccount: (UserId) -> Unit,
+ val onSubscription: () -> Unit,
+ val onReportBug: () -> Unit,
+ val onPasswordManagement: () -> Unit,
+ val onRecoveryEmail: () -> Unit,
+ val onRequestNotificationPermission: () -> Unit
+ )
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherRouter.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherRouter.kt
new file mode 100644
index 0000000000..35d3bc8125
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherRouter.kt
@@ -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 .
+ */
+
+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()
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherRouterViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherRouterViewModel.kt
new file mode 100644
index 0000000000..e5c80f9c03
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherRouterViewModel.kt
@@ -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 .
+ */
+
+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.toState(): OnboardingEligibilityState {
+ val preference = this.getOrNull() ?: return OnboardingEligibilityState.Required
+ return if (preference.display) OnboardingEligibilityState.Required else OnboardingEligibilityState.NotRequired
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherViewModel.kt
new file mode 100644
index 0000000000..cd547b54e3
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherViewModel.kt
@@ -0,0 +1,192 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation
+
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import ch.protonmail.android.mailnotifications.presentation.NotificationPermissionOrchestrator
+import ch.protonmail.android.navigation.model.LauncherState
+import ch.protonmail.android.navigation.model.LauncherState.AccountNeeded
+import ch.protonmail.android.navigation.model.LauncherState.PrimaryExist
+import ch.protonmail.android.navigation.model.LauncherState.Processing
+import ch.protonmail.android.navigation.model.LauncherState.StepNeeded
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import me.proton.core.account.domain.entity.isDisabled
+import me.proton.core.account.domain.entity.isReady
+import me.proton.core.account.domain.entity.isStepNeeded
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.accountmanager.presentation.observe
+import me.proton.core.accountmanager.presentation.onAccountCreateAddressFailed
+import me.proton.core.accountmanager.presentation.onAccountCreateAddressNeeded
+import me.proton.core.accountmanager.presentation.onAccountDeviceSecretNeeded
+import me.proton.core.accountmanager.presentation.onAccountTwoPassModeFailed
+import me.proton.core.accountmanager.presentation.onAccountTwoPassModeNeeded
+import me.proton.core.accountmanager.presentation.onSessionSecondFactorNeeded
+import me.proton.core.auth.presentation.AuthOrchestrator
+import me.proton.core.auth.presentation.onAddAccountResult
+import me.proton.core.domain.entity.UserId
+import me.proton.core.plan.presentation.PlansOrchestrator
+import me.proton.core.report.presentation.ReportOrchestrator
+import me.proton.core.usersettings.presentation.UserSettingsOrchestrator
+import javax.inject.Inject
+
+@HiltViewModel
+class LauncherViewModel @Inject constructor(
+ private val accountManager: AccountManager,
+ private val authOrchestrator: AuthOrchestrator,
+ private val notificationPermissionOrchestrator: NotificationPermissionOrchestrator,
+ private val plansOrchestrator: PlansOrchestrator,
+ private val reportOrchestrator: ReportOrchestrator,
+ private val userSettingsOrchestrator: UserSettingsOrchestrator
+) : ViewModel() {
+
+ val state: StateFlow = accountManager.getAccounts().map { accounts ->
+ when {
+ accounts.isEmpty() || accounts.all { it.isDisabled() } -> AccountNeeded
+ accounts.any { it.isReady() } -> PrimaryExist
+ accounts.any { it.isStepNeeded() } -> StepNeeded
+ else -> Processing
+ }
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Lazily,
+ initialValue = Processing
+ )
+
+ fun register(context: AppCompatActivity) {
+ authOrchestrator.register(context)
+ plansOrchestrator.register(context)
+ reportOrchestrator.register(context)
+ userSettingsOrchestrator.register(context)
+ notificationPermissionOrchestrator.register(context)
+
+ authOrchestrator.onAddAccountResult { result ->
+ viewModelScope.launch {
+ if (result == null && getPrimaryUserIdOrNull() == null) {
+ context.finish()
+ }
+ }
+ }
+
+ accountManager.observe(context.lifecycle, Lifecycle.State.CREATED)
+ .onAccountTwoPassModeFailed { accountManager.disableAccount(it.userId) }
+ .onAccountCreateAddressFailed { accountManager.disableAccount(it.userId) }
+ .onSessionSecondFactorNeeded { authOrchestrator.startSecondFactorWorkflow(it) }
+ .onAccountTwoPassModeNeeded { authOrchestrator.startTwoPassModeWorkflow(it) }
+ .onAccountCreateAddressNeeded { authOrchestrator.startChooseAddressWorkflow(it) }
+ .onAccountDeviceSecretNeeded { authOrchestrator.startDeviceSecretWorkflow(it) }
+ }
+
+ fun unregister() {
+ authOrchestrator.unregister()
+ plansOrchestrator.unregister()
+ reportOrchestrator.unregister()
+ userSettingsOrchestrator.unregister()
+ notificationPermissionOrchestrator.unregister()
+ }
+
+ fun submit(action: Action) {
+ viewModelScope.launch {
+ when (action) {
+ Action.AddAccount -> onAddAccount()
+ Action.OpenPasswordManagement -> onOpenPasswordManagement()
+ Action.OpenRecoveryEmail -> onOpenRecoveryEmail()
+ Action.OpenSecurityKeys -> onOpenSecurityKeys()
+ Action.OpenReport -> onOpenReport()
+ Action.OpenSubscription -> onOpenSubscription()
+ Action.RequestNotificationPermission -> onRequestNotificationPermission()
+ is Action.SignIn -> onSignIn(action.userId)
+ is Action.Switch -> onSwitch(action.userId)
+ }
+ }
+ }
+
+ private fun onAddAccount() {
+ authOrchestrator.startAddAccountWorkflow()
+ }
+
+ private suspend fun onOpenPasswordManagement() {
+ getPrimaryUserIdOrNull()?.let {
+ userSettingsOrchestrator.startPasswordManagementWorkflow(it)
+ }
+ }
+
+ private suspend fun onOpenRecoveryEmail() {
+ getPrimaryUserIdOrNull()?.let {
+ userSettingsOrchestrator.startUpdateRecoveryEmailWorkflow(it)
+ }
+ }
+
+ private suspend fun onOpenSecurityKeys() {
+ getPrimaryUserIdOrNull()?.let {
+ userSettingsOrchestrator.startSecurityKeysWorkflow(it)
+ }
+ }
+
+ private suspend fun onOpenReport() = viewModelScope.launch {
+ reportOrchestrator.startBugReport()
+ }
+
+ private suspend fun onOpenSubscription() {
+ getPrimaryUserIdOrNull()?.let {
+ plansOrchestrator.showCurrentPlanWorkflow(it)
+ }
+ }
+
+ private fun onRequestNotificationPermission() {
+ notificationPermissionOrchestrator.requestPermissionIfRequired()
+ }
+
+ private suspend fun onSignIn(userId: UserId?) {
+ val account = userId?.let { getAccountOrNull(it) }
+ authOrchestrator.startLoginWorkflow(account?.username)
+ }
+
+ private suspend fun onSwitch(userId: UserId) {
+ val account = getAccountOrNull(userId) ?: return
+ when {
+ account.isDisabled() -> onSignIn(userId)
+ account.isReady() -> accountManager.setAsPrimary(userId)
+ }
+ }
+
+ private suspend fun getAccountOrNull(it: UserId) = accountManager.getAccount(it).firstOrNull()
+ private suspend fun getPrimaryUserIdOrNull() = accountManager.getPrimaryUserId().firstOrNull()
+
+ sealed interface Action {
+
+ data object AddAccount : Action
+ data object OpenPasswordManagement : Action
+ data object OpenRecoveryEmail : Action
+ data object OpenSecurityKeys : Action
+ data object OpenReport : Action
+ data object OpenSubscription : Action
+ data object RequestNotificationPermission : Action
+ data class SignIn(val userId: UserId?) : Action
+ data class Switch(val userId: UserId) : Action
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinkHelperImpl.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinkHelperImpl.kt
new file mode 100644
index 0000000000..3b5c9c6afe
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinkHelperImpl.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.navigation.deeplinks
+
+import android.content.Context
+import android.content.Intent
+import ch.protonmail.android.MainActivity
+import ch.protonmail.android.mailnotifications.domain.NotificationsDeepLinkHelper
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+
+class NotificationsDeepLinkHelperImpl @Inject constructor(
+ @ApplicationContext private val context: Context
+) : NotificationsDeepLinkHelper {
+
+ override fun buildMessageDeepLinkIntent(
+ notificationId: String,
+ messageId: String,
+ userId: String
+ ): Intent = Intent(
+ Intent.ACTION_VIEW,
+ buildMessageDeepLinkUri(notificationId, messageId, userId),
+ context,
+ MainActivity::class.java
+ )
+
+ override fun buildMessageGroupDeepLinkIntent(notificationId: String, userId: String): Intent = Intent(
+ Intent.ACTION_VIEW,
+ buildMessageGroupDeepLinkUri(notificationId, userId),
+ context,
+ MainActivity::class.java
+ )
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinksViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinksViewModel.kt
new file mode 100644
index 0000000000..98c74d7666
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinksViewModel.kt
@@ -0,0 +1,213 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation.deeplinks
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import ch.protonmail.android.mailcommon.domain.model.ConversationId
+import ch.protonmail.android.mailcommon.domain.model.DataError
+import ch.protonmail.android.mailcommon.domain.usecase.GetPrimaryAddress
+import ch.protonmail.android.mailconversation.domain.repository.ConversationRepository
+import ch.protonmail.android.mailmessage.domain.model.Message
+import ch.protonmail.android.mailmessage.domain.model.MessageId
+import ch.protonmail.android.mailmessage.domain.repository.MessageRepository
+import ch.protonmail.android.navigation.deeplinks.NotificationsDeepLinksViewModel.State.NavigateToInbox
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.launch
+import me.proton.core.account.domain.entity.AccountState
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.accountmanager.domain.getAccounts
+import me.proton.core.domain.entity.UserId
+import me.proton.core.mailsettings.domain.entity.ViewMode
+import me.proton.core.mailsettings.domain.repository.MailSettingsRepository
+import me.proton.core.network.domain.NetworkManager
+import me.proton.core.network.domain.NetworkStatus
+import timber.log.Timber
+import javax.inject.Inject
+import kotlin.coroutines.CoroutineContext
+
+@HiltViewModel
+class NotificationsDeepLinksViewModel @Inject constructor(
+ private val networkManager: NetworkManager,
+ private val accountManager: AccountManager,
+ private val getPrimaryAddress: GetPrimaryAddress,
+ private val messageRepository: MessageRepository,
+ private val conversationRepository: ConversationRepository,
+ private val mailSettingsRepository: MailSettingsRepository
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(State.Launched)
+ val state: StateFlow = _state
+
+ private var navigateJob: Job? = null
+
+ fun navigateToMessage(messageId: String, userId: String) {
+ if (isOffline()) {
+ navigateToInbox(userId)
+ } else {
+ navigateToMessageOrConversation(messageId, UserId(userId))
+ }
+ }
+
+ fun navigateToInbox(userId: String) {
+ viewModelScope.launch {
+ val activeUserId = accountManager.getPrimaryUserId().firstOrNull()
+ if (activeUserId != null && activeUserId.id != userId) {
+ switchUserAndNavigateToInbox(userId)
+ } else {
+ _state.value = NavigateToInbox.ActiveUser
+ }
+ }
+ }
+
+ private suspend fun switchUserAndNavigateToInbox(userId: String) {
+ val switchAccountResult = switchActiveUserIfRequiredTo(userId)
+ _state.value = when (switchAccountResult) {
+ AccountSwitchResult.AccountSwitchError -> NavigateToInbox.ActiveUser
+ is AccountSwitchResult.AccountSwitched -> NavigateToInbox.ActiveUserSwitched(switchAccountResult.newEmail)
+ AccountSwitchResult.NotRequired -> NavigateToInbox.ActiveUser
+ }
+ }
+
+ private fun navigateToMessageOrConversation(messageId: String, userId: UserId) {
+ navigateJob?.cancel()
+ navigateJob = viewModelScope.launch {
+ when (val switchAccountResult = switchActiveUserIfRequiredTo(userId.id)) {
+ AccountSwitchResult.AccountSwitchError -> navigateToInbox(userId.id)
+ is AccountSwitchResult.AccountSwitched -> navigateToMessageOrConversation(
+ this.coroutineContext,
+ messageId,
+ switchAccountResult.newUserId,
+ switchAccountResult.newEmail
+ )
+
+ AccountSwitchResult.NotRequired -> navigateToMessageOrConversation(
+ this.coroutineContext,
+ messageId,
+ userId
+ )
+ }
+ }
+ }
+
+ private suspend fun navigateToMessageOrConversation(
+ coroutineContext: CoroutineContext,
+ messageId: String,
+ userId: UserId,
+ switchedAccountEmail: String? = null
+ ) {
+ messageRepository.observeCachedMessage(userId, MessageId(messageId))
+ .distinctUntilChanged()
+ .collectLatest { messageResult ->
+ messageResult
+ .onLeft {
+ if (it != DataError.Local.NoDataCached) navigateToInbox(userId.id)
+ }
+ .onRight { message ->
+ if (isConversationModeEnabled(userId)) {
+ navigateToConversation(message, userId, switchedAccountEmail)
+ } else {
+ _state.value = State.NavigateToMessageDetails(message.messageId, switchedAccountEmail)
+ }
+ coroutineContext.cancel()
+ }
+ }
+ }
+
+ private suspend fun switchActiveUserIfRequiredTo(userId: String): AccountSwitchResult {
+ return if (accountManager.getPrimaryUserId().firstOrNull()?.id == userId) {
+ AccountSwitchResult.NotRequired
+ } else {
+ val targetAccount = accountManager.getAccounts(AccountState.Ready)
+ .firstOrNull()
+ ?.find { it.userId.id == userId }
+ ?: return AccountSwitchResult.AccountSwitchError
+
+ accountManager.setAsPrimary(UserId(userId))
+ val emailAddress = getPrimaryAddress(UserId(userId)).getOrNull()?.email
+ AccountSwitchResult.AccountSwitched(targetAccount.userId, emailAddress ?: "")
+ }
+ }
+
+ private suspend fun isConversationModeEnabled(userId: UserId): Boolean =
+ mailSettingsRepository.getMailSettings(userId)
+ .viewMode
+ ?.value == ViewMode.ConversationGrouping.value
+
+ private suspend fun navigateToConversation(
+ message: Message,
+ userId: UserId,
+ switchedAccountEmail: String?
+ ) {
+ conversationRepository.observeConversation(
+ userId,
+ message.conversationId,
+ true
+ ).collectLatest { conversationResult ->
+ conversationResult
+ .onLeft {
+ Timber.d("Conversation not found: $it")
+ if (it != DataError.Local.NoDataCached) navigateToInbox(userId.id)
+ }
+ .onRight { conversation ->
+ _state.value =
+ State.NavigateToConversation(
+ conversationId = conversation.conversationId,
+ userSwitchedEmail = switchedAccountEmail
+ )
+ }
+ }
+ }
+
+ private fun isOffline() = networkManager.networkStatus == NetworkStatus.Disconnected
+
+ sealed interface State {
+ data object Launched : State
+
+ sealed interface NavigateToInbox : State {
+ data object ActiveUser : NavigateToInbox
+ data class ActiveUserSwitched(val email: String) : NavigateToInbox
+ }
+
+ data class NavigateToMessageDetails(
+ val messageId: MessageId,
+ val userSwitchedEmail: String? = null
+ ) : State
+
+ data class NavigateToConversation(
+ val conversationId: ConversationId,
+ val scrollToMessageId: MessageId? = null,
+ val userSwitchedEmail: String? = null
+ ) : State
+ }
+
+ private sealed interface AccountSwitchResult {
+ data object NotRequired : AccountSwitchResult
+ data class AccountSwitched(val newUserId: UserId, val newEmail: String) : AccountSwitchResult
+
+ data object AccountSwitchError : AccountSwitchResult
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/listener/NavHostControllerExtension.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/listener/NavHostControllerExtension.kt
new file mode 100644
index 0000000000..219fddd454
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/listener/NavHostControllerExtension.kt
@@ -0,0 +1,50 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation.listener
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.navigation.NavHostController
+import timber.log.Timber
+
+@Composable
+@NonRestartableComposable
+fun NavHostController.withDestinationChangedObservableEffect(): NavHostController {
+
+ val lifecycle = LocalLifecycleOwner.current.lifecycle
+
+ DisposableEffect(lifecycle, this) {
+ val observer = NavigationLifeCycleObserver(
+ this@withDestinationChangedObservableEffect,
+ navListener = { _, destination, _ ->
+ Timber.tag("NavController").d("Navigating to ${destination.route}")
+ }
+ )
+
+ lifecycle.addObserver(observer)
+
+ onDispose {
+ observer.dispose()
+ lifecycle.removeObserver(observer)
+ }
+ }
+ return this
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/listener/NavigationLifeCycleObserver.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/listener/NavigationLifeCycleObserver.kt
new file mode 100644
index 0000000000..1e223036f3
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/listener/NavigationLifeCycleObserver.kt
@@ -0,0 +1,42 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation.listener
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.navigation.NavController
+
+internal class NavigationLifeCycleObserver(
+ private val navController: NavController,
+ private val navListener: NavController.OnDestinationChangedListener
+) : LifecycleEventObserver {
+
+ override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+ if (event == Lifecycle.Event.ON_RESUME) {
+ navController.addOnDestinationChangedListener(navListener)
+ } else if (event == Lifecycle.Event.ON_PAUSE) {
+ navController.removeOnDestinationChangedListener(navListener)
+ }
+ }
+
+ fun dispose() {
+ navController.removeOnDestinationChangedListener(navListener)
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/model/Destination.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/model/Destination.kt
new file mode 100644
index 0000000000..4f8c54f7c6
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/model/Destination.kt
@@ -0,0 +1,261 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation.model
+
+import ch.protonmail.android.feature.account.SignOutAccountDialog.USER_ID_KEY
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsViewItemMode
+import ch.protonmail.android.mailbugreport.presentation.ui.ApplicationLogsPeekView.ApplicationLogsViewMode
+import ch.protonmail.android.mailcommon.domain.model.BasicContactInfo
+import ch.protonmail.android.mailcommon.domain.model.ConversationId
+import ch.protonmail.android.mailcommon.domain.model.encode
+import ch.protonmail.android.mailcomposer.domain.model.SenderEmail
+import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen.DraftActionForShareKey
+import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen.DraftMessageIdKey
+import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen.SerializedDraftActionKey
+import ch.protonmail.android.mailcomposer.presentation.ui.SetMessagePasswordScreen
+import ch.protonmail.android.mailcontact.presentation.contactdetails.ContactDetailsScreen.ContactDetailsContactIdKey
+import ch.protonmail.android.mailcontact.presentation.contactform.ContactFormScreen.ContactFormBasicContactInfoKey
+import ch.protonmail.android.mailcontact.presentation.contactform.ContactFormScreen.ContactFormContactIdKey
+import ch.protonmail.android.mailcontact.presentation.contactgroupdetails.ContactGroupDetailsScreen.ContactGroupDetailsLabelIdKey
+import ch.protonmail.android.mailcontact.presentation.contactgroupform.ContactGroupFormScreen.ContactGroupFormLabelIdKey
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreen.ConversationIdKey
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreen.FilterByLocationKey
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreen.ScrollToMessageIdKey
+import ch.protonmail.android.maildetail.presentation.ui.EntireMessageBodyScreen
+import ch.protonmail.android.maildetail.presentation.ui.EntireMessageBodyScreen.INPUT_PARAMS_KEY
+import ch.protonmail.android.maildetail.presentation.ui.MessageDetailScreen.MESSAGE_ID_KEY
+import ch.protonmail.android.maillabel.domain.model.MailLabel
+import ch.protonmail.android.maillabel.presentation.folderform.FolderFormScreen.FolderFormLabelIdKey
+import ch.protonmail.android.maillabel.presentation.folderparentlist.ParentFolderListScreen.ParentFolderListLabelIdKey
+import ch.protonmail.android.maillabel.presentation.folderparentlist.ParentFolderListScreen.ParentFolderListParentLabelIdKey
+import ch.protonmail.android.maillabel.presentation.labelform.LabelFormScreen.LabelFormLabelIdKey
+import ch.protonmail.android.mailmessage.domain.model.DraftAction
+import ch.protonmail.android.mailmessage.domain.model.MessageId
+import ch.protonmail.android.mailmessage.presentation.model.ViewModePreference
+import ch.protonmail.android.mailsettings.domain.model.SwipeActionDirection
+import ch.protonmail.android.mailsettings.domain.model.autolock.AutoLockInsertionMode
+import ch.protonmail.android.mailsettings.presentation.settings.autolock.ui.pin.AutoLockPinScreen.AutoLockPinModeKey
+import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.EditSwipeActionPreferenceScreen.SWIPE_DIRECTION_KEY
+import me.proton.core.contact.domain.entity.ContactId
+import me.proton.core.domain.entity.UserId
+import me.proton.core.label.domain.entity.LabelId
+import me.proton.core.util.kotlin.serialize
+
+sealed class Destination(val route: String) {
+
+ object Screen {
+ object Mailbox : Destination("mailbox")
+
+ object Conversation : Destination(
+ "mailbox/conversation/${ConversationIdKey.wrap()}/" +
+ "${ScrollToMessageIdKey.wrap()}/${FilterByLocationKey.wrap()}"
+ ) {
+ operator fun invoke(
+ conversationId: ConversationId,
+ scrollToMessageId: MessageId? = null,
+ filterByLocation: MailLabel? = null
+ ) = route.replace(ConversationIdKey.wrap(), conversationId.id)
+ .replace(ScrollToMessageIdKey.wrap(), scrollToMessageId?.id ?: "null")
+ .replace(FilterByLocationKey.wrap(), filterByLocation?.id?.labelId?.id ?: "null")
+ }
+
+ object Message : Destination("mailbox/message/${MESSAGE_ID_KEY.wrap()}") {
+
+ operator fun invoke(messageId: MessageId) = route.replace(MESSAGE_ID_KEY.wrap(), messageId.id)
+ }
+
+ data object EntireMessageBody : Destination(
+ "mailbox/message/${MESSAGE_ID_KEY.wrap()}/body/${INPUT_PARAMS_KEY.wrap()}"
+ ) {
+
+ operator fun invoke(
+ messageId: MessageId,
+ shouldShowEmbeddedImages: Boolean,
+ shouldShowRemoteContent: Boolean,
+ viewModePreference: ViewModePreference
+ ) = route.replace(MESSAGE_ID_KEY.wrap(), messageId.id)
+ .replace(
+ INPUT_PARAMS_KEY.wrap(),
+ EntireMessageBodyScreen.InputParams(
+ shouldShowEmbeddedImages,
+ shouldShowRemoteContent,
+ viewModePreference
+ ).serialize()
+ )
+ }
+
+ object Composer : Destination("composer")
+ object SetMessagePassword : Destination(
+ "composer/setMessagePassword/${SetMessagePasswordScreen.InputParamsKey.wrap()}"
+ ) {
+ operator fun invoke(messageId: MessageId, senderEmail: SenderEmail) = route.replace(
+ SetMessagePasswordScreen.InputParamsKey.wrap(),
+ SetMessagePasswordScreen.InputParams(messageId, senderEmail).serialize()
+ )
+ }
+
+ object EditDraftComposer : Destination("composer/${DraftMessageIdKey.wrap()}") {
+
+ operator fun invoke(messageId: MessageId) = route.replace(DraftMessageIdKey.wrap(), messageId.id)
+ }
+
+ object ShareFileComposer : Destination("composer/share/${DraftActionForShareKey.wrap()}") {
+
+ operator fun invoke(draftAction: DraftAction) = route.replace(
+ DraftActionForShareKey.wrap(),
+ draftAction.serialize()
+ )
+ }
+
+ object MessageActionComposer : Destination("composer/action/${SerializedDraftActionKey.wrap()}") {
+
+ operator fun invoke(action: DraftAction) =
+ route.replace(SerializedDraftActionKey.wrap(), action.serialize())
+ }
+
+ object Settings : Destination("settings")
+ object AccountSettings : Destination("settings/account")
+ object AlternativeRoutingSettings : Destination("settings/alternativeRouting")
+ object AutoLockSettings : Destination("settings/autolock")
+ object AutoLockPinScreen : Destination("settings/autolock/pin/${AutoLockPinModeKey.wrap()}") {
+
+ operator fun invoke(mode: AutoLockInsertionMode) =
+ route.replace(AutoLockPinModeKey.wrap(), mode.serialize())
+ }
+
+ object CombinedContactsSettings : Destination("settings/combinedContacts")
+ object ConversationModeSettings : Destination("settings/account/conversationMode")
+ object AutoDeleteSettings : Destination("settings/account/autoDelete")
+ object DefaultEmailSettings : Destination("settings/account/defaultEmail")
+ object DisplayNameSettings : Destination("settings/account/displayName")
+ object PrivacySettings : Destination("settings/account/privacy")
+ object LanguageSettings : Destination("settings/appLanguage")
+ object CustomizeToolbar : Destination("settings/customizeToolbar")
+ object SwipeActionsSettings : Destination("settings/swipeActions")
+ object EditSwipeActionSettings : Destination("settings/swipeActions/edit/${SWIPE_DIRECTION_KEY.wrap()}") {
+
+ operator fun invoke(direction: SwipeActionDirection) =
+ route.replace(SWIPE_DIRECTION_KEY.wrap(), direction.name)
+ }
+
+ object ThemeSettings : Destination("settings/theme")
+ object Notifications : Destination("settings/notifications")
+ object ApplicationLogs : Destination("settings/applicationLogs")
+ object ApplicationLogsView : Destination("settings/applicationLogs/view/${ApplicationLogsViewMode.wrap()}") {
+ operator fun invoke(item: ApplicationLogsViewItemMode) =
+ route.replace(ApplicationLogsViewMode.wrap(), item.serialize())
+ }
+ object DeepLinksHandler : Destination("deepLinksHandler")
+ object LabelList : Destination("labelList")
+ object CreateLabel : Destination("labelForm")
+ object EditLabel : Destination("labelForm/${LabelFormLabelIdKey.wrap()}") {
+
+ operator fun invoke(labelId: LabelId) = route.replace(LabelFormLabelIdKey.wrap(), labelId.id)
+ }
+
+ object FolderList : Destination("folderList")
+ object CreateFolder : Destination("folderForm")
+ object EditFolder : Destination("folderForm/${FolderFormLabelIdKey.wrap()}") {
+
+ operator fun invoke(labelId: LabelId) = route.replace(FolderFormLabelIdKey.wrap(), labelId.id)
+ }
+
+ object ParentFolderList : Destination(
+ "parentFolderList/${ParentFolderListLabelIdKey.wrap()}/${ParentFolderListParentLabelIdKey.wrap()}"
+ ) {
+
+ operator fun invoke(labelId: LabelId?, parentLabelId: LabelId?) = run {
+ route.replace(
+ ParentFolderListLabelIdKey.wrap(), labelId?.id ?: "null"
+ ).replace(
+ ParentFolderListParentLabelIdKey.wrap(), parentLabelId?.id ?: "null"
+ )
+ }
+ }
+
+ object Contacts : Destination("contacts")
+ object ContactDetails : Destination("contacts/contact/${ContactDetailsContactIdKey.wrap()}") {
+
+ operator fun invoke(contactId: ContactId) = route.replace(ContactDetailsContactIdKey.wrap(), contactId.id)
+ }
+
+ object CreateContact : Destination("contacts/contact/form")
+ object AddContact : Destination(
+ "contacts/addContact/${ContactFormBasicContactInfoKey.wrap()}/form"
+ ) {
+ operator fun invoke(contactInfo: BasicContactInfo): String {
+ return route.replace(
+ ContactFormBasicContactInfoKey.wrap(),
+ contactInfo.encode().serialize()
+ )
+ }
+ }
+
+ object EditContact : Destination("contacts/contact/${ContactFormContactIdKey.wrap()}/form") {
+
+ operator fun invoke(contactId: ContactId) = route.replace(ContactFormContactIdKey.wrap(), contactId.id)
+ }
+
+ object ContactGroupDetails : Destination("contacts/group/${ContactGroupDetailsLabelIdKey.wrap()}") {
+
+ operator fun invoke(labelId: LabelId) = route.replace(ContactGroupDetailsLabelIdKey.wrap(), labelId.id)
+ }
+
+ object CreateContactGroup : Destination("contacts/group/form")
+ object EditContactGroup : Destination("contacts/group/${ContactGroupFormLabelIdKey.wrap()}/form") {
+
+ operator fun invoke(labelId: LabelId) = route.replace(ContactGroupFormLabelIdKey.wrap(), labelId.id)
+ }
+
+ 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 PostSubscription : Destination("postSubscription")
+
+ object Upselling {
+ data object StandaloneMailbox : Destination("upselling/standalone/mailbox")
+ data object StandaloneMailboxPromo : Destination("upselling/standalone/mailboxPromo")
+ data object StandaloneNavbar : Destination("upselling/standalone/navbar")
+ }
+ }
+
+ object Dialog {
+ object SignOut : Destination("signout/${USER_ID_KEY.wrap()}") {
+
+ operator fun invoke(userId: UserId?) = route.replace(USER_ID_KEY.wrap(), userId?.id ?: " ")
+ }
+
+ object RemoveAccount : Destination("remove/${USER_ID_KEY.wrap()}") {
+
+ operator fun invoke(userId: UserId?) = route.replace(USER_ID_KEY.wrap(), userId?.id ?: " ")
+ }
+ }
+}
+
+/**
+ * Wrap a key in the format required by the Navigation framework: `{key_name}`
+ */
+private fun String.wrap() = "{$this}"
+
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/model/HomeState.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/model/HomeState.kt
new file mode 100644
index 0000000000..b3e8f6ec2f
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/model/HomeState.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.navigation.model
+
+import ch.protonmail.android.mailcommon.presentation.Effect
+import ch.protonmail.android.mailcomposer.domain.model.MessageSendingStatus
+import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogState
+import me.proton.core.network.domain.NetworkStatus
+
+data class HomeState(
+ val notificationPermissionDialogState: NotificationPermissionDialogState,
+ val networkStatusEffect: Effect,
+ val messageSendingStatusEffect: Effect,
+ val navigateToEffect: Effect,
+ val startedFromLauncher: Boolean
+) {
+
+ companion object {
+
+ val Initial = HomeState(
+ notificationPermissionDialogState = NotificationPermissionDialogState.Hidden,
+ networkStatusEffect = Effect.empty(),
+ messageSendingStatusEffect = Effect.empty(),
+ navigateToEffect = Effect.empty(),
+ startedFromLauncher = false
+ )
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/model/LauncherState.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/model/LauncherState.kt
new file mode 100644
index 0000000000..fbe3f09948
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/model/LauncherState.kt
@@ -0,0 +1,21 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation.model
+
+enum class LauncherState { Processing, AccountNeeded, PrimaryExist, StepNeeded }
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/model/OnboardingEligibilityState.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/model/OnboardingEligibilityState.kt
new file mode 100644
index 0000000000..9b1f1209ad
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/model/OnboardingEligibilityState.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation.model
+
+internal sealed interface OnboardingEligibilityState {
+ data object Required : OnboardingEligibilityState
+ data object NotRequired : OnboardingEligibilityState
+ data object Loading : OnboardingEligibilityState
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/model/SavedStateKey.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/model/SavedStateKey.kt
new file mode 100644
index 0000000000..95e646cc93
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/model/SavedStateKey.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation.model
+
+sealed class SavedStateKey(val key: String) {
+
+ object CurrentParentFolderId : SavedStateKey("current_parent_folder_id")
+ object SelectedContactEmailIds : SavedStateKey("selected_contacts_email_ids")
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/onboarding/Onboarding.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/onboarding/Onboarding.kt
new file mode 100644
index 0000000000..50439302bc
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/onboarding/Onboarding.kt
@@ -0,0 +1,55 @@
+/*
+ * 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 .
+ */
+
+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.listener.withDestinationChangedObservableEffect
+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()
+ .withDestinationChangedObservableEffect()
+ val onboardingStepViewModel = hiltViewModel()
+
+ val exitAction = remember {
+ {
+ onboardingStepViewModel.submit(OnboardingStepAction.MarkOnboardingComplete)
+ }
+ }
+
+ NavHost(
+ modifier = Modifier.fillMaxSize(),
+ navController = navController,
+ startDestination = Destination.Screen.Onboarding.MainScreen.route
+ ) {
+ addOnboarding(navController, exitAction)
+ addOnboardingUpselling(exitAction)
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/onboarding/OnboardingStepViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/onboarding/OnboardingStepViewModel.kt
new file mode 100644
index 0000000000..62966b8d66
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/onboarding/OnboardingStepViewModel.kt
@@ -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 .
+ */
+
+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
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/route/DeepLinkRoutes.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/route/DeepLinkRoutes.kt
new file mode 100644
index 0000000000..387b494778
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/route/DeepLinkRoutes.kt
@@ -0,0 +1,122 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation.route
+
+import android.content.Context
+import android.os.Bundle
+import android.widget.Toast
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.platform.LocalContext
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.composable
+import androidx.navigation.navDeepLink
+import ch.protonmail.android.R
+import ch.protonmail.android.mailnotifications.domain.NotificationInteraction
+import ch.protonmail.android.mailnotifications.domain.NotificationsDeepLinkHelper
+import ch.protonmail.android.mailnotifications.domain.resolveNotificationInteraction
+import ch.protonmail.android.navigation.deeplinks.NotificationsDeepLinksViewModel
+import ch.protonmail.android.navigation.model.Destination
+
+@Suppress("ComplexMethod", "LongMethod")
+internal fun NavGraphBuilder.addDeepLinkHandler(navController: NavHostController) {
+ composable(
+ route = Destination.Screen.DeepLinksHandler.route,
+ deepLinks = listOf(
+ navDeepLink { uriPattern = NotificationsDeepLinkHelper.DeepLinkMessageTemplate },
+ navDeepLink { uriPattern = NotificationsDeepLinkHelper.DeepLinkMessageGroupTemplate }
+ )
+ ) {
+ val context = LocalContext.current
+ val viewModel: NotificationsDeepLinksViewModel = hiltViewModel()
+ val state = viewModel.state.collectAsState().value
+
+ LaunchedEffect(key1 = state) {
+ when (state) {
+ is NotificationsDeepLinksViewModel.State.Launched -> {
+ val interaction = resolveNotificationInteraction(
+ userId = it.arguments.userId,
+ messageId = it.arguments.messageId,
+ action = it.arguments.action
+ )
+
+ when (interaction) {
+ is NotificationInteraction.SingleTap -> {
+ viewModel.navigateToMessage(messageId = interaction.messageId, userId = interaction.userId)
+ }
+
+ is NotificationInteraction.GroupTap -> {
+ viewModel.navigateToInbox(interaction.userId)
+ }
+
+ NotificationInteraction.NoAction -> Unit
+ }
+ }
+
+ is NotificationsDeepLinksViewModel.State.NavigateToInbox.ActiveUser -> {
+ navController.navigate(Destination.Screen.Mailbox.route) {
+ popUpTo(navController.graph.id) { inclusive = false }
+ }
+ }
+
+ is NotificationsDeepLinksViewModel.State.NavigateToInbox.ActiveUserSwitched -> {
+ navController.navigate(Destination.Screen.Mailbox.route) {
+ popUpTo(navController.graph.id) { inclusive = true }
+ }
+ showUserSwitchedEmailIfRequired(context, state.email)
+ }
+
+ is NotificationsDeepLinksViewModel.State.NavigateToMessageDetails -> {
+ navController.navigate(Destination.Screen.Message(state.messageId)) {
+ popUpTo(Destination.Screen.Mailbox.route) { inclusive = false }
+ }
+ showUserSwitchedEmailIfRequired(context, state.userSwitchedEmail)
+ }
+
+ is NotificationsDeepLinksViewModel.State.NavigateToConversation -> {
+ navController.navigate(Destination.Screen.Conversation(conversationId = state.conversationId)) {
+ popUpTo(Destination.Screen.Mailbox.route) { inclusive = false }
+ }
+ showUserSwitchedEmailIfRequired(context, state.userSwitchedEmail)
+ }
+ }
+ }
+ }
+}
+
+private fun showUserSwitchedEmailIfRequired(context: Context, email: String?) {
+ if (email.isNullOrBlank()) return
+
+ Toast.makeText(
+ context,
+ context.getString(R.string.notification_switched_account, email),
+ Toast.LENGTH_LONG
+ ).show()
+}
+
+private val Bundle?.messageId: String?
+ get() = this?.getString("messageId")
+
+private val Bundle?.userId: String?
+ get() = this?.getString("userId")
+
+private val Bundle?.action: String?
+ get() = this?.getString("action")
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/route/HomeRoutes.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/route/HomeRoutes.kt
new file mode 100644
index 0000000000..5e0f6e89da
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/route/HomeRoutes.kt
@@ -0,0 +1,648 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation.route
+
+import android.net.Uri
+import androidx.compose.material.DrawerState
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.dialog
+import ch.protonmail.android.MainActivity
+import ch.protonmail.android.feature.account.RemoveAccountDialog
+import ch.protonmail.android.feature.account.SignOutAccountDialog
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsViewItemMode
+import ch.protonmail.android.mailcommon.domain.model.ConversationId
+import ch.protonmail.android.mailcommon.presentation.extension.navigateBack
+import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen
+import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen2
+import ch.protonmail.android.mailcomposer.presentation.ui.SetMessagePasswordScreen
+import ch.protonmail.android.mailcontact.presentation.contactdetails.ContactDetailsScreen
+import ch.protonmail.android.mailcontact.presentation.contactform.ContactFormScreen
+import ch.protonmail.android.mailcontact.presentation.contactgroupdetails.ContactGroupDetailsScreen
+import ch.protonmail.android.mailcontact.presentation.contactgroupform.ContactGroupFormScreen
+import ch.protonmail.android.mailcontact.presentation.contactlist.ui.ContactListScreen
+import ch.protonmail.android.mailcontact.presentation.contactsearch.ContactSearchScreen
+import ch.protonmail.android.mailcontact.presentation.managemembers.ManageMembersScreen
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetail
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreen
+import ch.protonmail.android.maildetail.presentation.ui.EntireMessageBodyScreen
+import ch.protonmail.android.maildetail.presentation.ui.MessageDetail
+import ch.protonmail.android.maildetail.presentation.ui.MessageDetailScreen
+import ch.protonmail.android.maillabel.presentation.folderform.FolderFormScreen
+import ch.protonmail.android.maillabel.presentation.folderlist.FolderListScreen
+import ch.protonmail.android.maillabel.presentation.folderparentlist.ParentFolderListScreen
+import ch.protonmail.android.maillabel.presentation.labelform.LabelFormScreen
+import ch.protonmail.android.maillabel.presentation.labellist.LabelListScreen
+import ch.protonmail.android.mailmailbox.domain.model.MailboxItemType
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreen
+import ch.protonmail.android.mailmessage.domain.model.DraftAction
+import ch.protonmail.android.mailmessage.domain.model.MessageId
+import ch.protonmail.android.mailsettings.presentation.settings.MainSettingsScreen
+import ch.protonmail.android.mailupselling.presentation.ui.postsubscription.PostSubscriptionScreen
+import ch.protonmail.android.navigation.model.Destination
+import ch.protonmail.android.navigation.model.Destination.Screen
+import ch.protonmail.android.navigation.model.SavedStateKey
+import me.proton.core.compose.navigation.get
+import me.proton.core.domain.entity.UserId
+import me.proton.core.util.kotlin.takeIfNotBlank
+
+
+internal fun NavGraphBuilder.addConversationDetail(actions: ConversationDetail.Actions) {
+ composable(route = Destination.Screen.Conversation.route) {
+ ConversationDetailScreen(actions = actions)
+ }
+}
+
+@Suppress("LongParameterList")
+internal fun NavGraphBuilder.addMailbox(
+ navController: NavHostController,
+ drawerState: DrawerState,
+ showOfflineSnackbar: () -> Unit,
+ showNormalSnackbar: (message: String) -> Unit,
+ showErrorSnackbar: (String) -> Unit,
+ onRequestNotificationPermission: () -> Unit
+) {
+ composable(route = Destination.Screen.Mailbox.route) {
+ MailboxScreen(
+ actions = MailboxScreen.Actions.Empty.copy(
+ navigateToMailboxItem = { request ->
+ val destination = when (request.shouldOpenInComposer) {
+ true -> Destination.Screen.EditDraftComposer(MessageId(request.itemId.value))
+ false -> when (request.itemType) {
+ MailboxItemType.Message -> Destination.Screen.Message(MessageId(request.itemId.value))
+ MailboxItemType.Conversation ->
+ Destination.Screen.Conversation(
+ ConversationId(request.itemId.value),
+ request.subItemId?.let { mailboxItemId ->
+ MessageId(mailboxItemId.value)
+ },
+ request.filterByLocation
+ )
+ }
+ }
+ navController.navigate(destination)
+ },
+ navigateToComposer = { navController.navigate(Destination.Screen.Composer.route) },
+ showOfflineSnackbar = showOfflineSnackbar,
+ showNormalSnackbar = showNormalSnackbar,
+ showErrorSnackbar = showErrorSnackbar,
+ onAddLabel = { navController.navigate(Destination.Screen.CreateLabel.route) },
+ onAddFolder = { navController.navigate(Destination.Screen.CreateFolder.route) },
+ onNavigateToStandaloneUpselling = { isPromo ->
+ if (isPromo) {
+ navController.navigate(Destination.Screen.Upselling.StandaloneMailboxPromo.route)
+ } else {
+ navController.navigate(Destination.Screen.Upselling.StandaloneMailbox.route)
+ }
+ },
+ onRequestNotificationPermission = onRequestNotificationPermission,
+ navigateToCustomizeToolbar = {
+ navController.navigate(Screen.CustomizeToolbar.route)
+ }
+ ),
+ drawerState = drawerState
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addMessageDetail(actions: MessageDetail.Actions) {
+ composable(route = Destination.Screen.Message.route) {
+ MessageDetailScreen(actions = actions)
+ }
+}
+
+internal fun NavGraphBuilder.addEntireMessageBody(
+ navController: NavHostController,
+ onOpenMessageBodyLink: (Uri) -> Unit
+) {
+ composable(route = Destination.Screen.EntireMessageBody.route) {
+ EntireMessageBodyScreen(
+ onBackClick = { navController.navigateBack() },
+ onOpenMessageBodyLink = onOpenMessageBodyLink
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addComposer(
+ navController: NavHostController,
+ activityActions: MainActivity.Actions,
+ showDraftSavedSnackbar: (messasgeId: MessageId) -> Unit,
+ showMessageSendingSnackbar: () -> Unit,
+ showMessageSendingOfflineSnackbar: () -> Unit,
+ showComposerV2: Boolean = false
+) {
+ val actions = ComposerScreen.Actions(
+ onCloseComposerClick = navController::navigateBack,
+ onSetMessagePasswordClick = { messageId, senderEmail ->
+ navController.navigate(Destination.Screen.SetMessagePassword(messageId, senderEmail))
+ },
+ showDraftSavedSnackbar = showDraftSavedSnackbar,
+ showMessageSendingSnackbar = showMessageSendingSnackbar,
+ showMessageSendingOfflineSnackbar = showMessageSendingOfflineSnackbar
+ )
+ if (showComposerV2) {
+ composable(route = Destination.Screen.Composer.route) { ComposerScreen2(actions) }
+ composable(route = Destination.Screen.EditDraftComposer.route) { ComposerScreen2(actions) }
+ composable(route = Destination.Screen.MessageActionComposer.route) { ComposerScreen2(actions) }
+ composable(route = Destination.Screen.ShareFileComposer.route) {
+ ComposerScreen2(
+ actions.copy(
+ onCloseComposerClick = { activityActions.finishActivity() }
+ )
+ )
+ }
+ } else {
+ composable(route = Destination.Screen.Composer.route) { ComposerScreen(actions) }
+ composable(route = Destination.Screen.EditDraftComposer.route) { ComposerScreen(actions) }
+ composable(route = Destination.Screen.MessageActionComposer.route) { ComposerScreen(actions) }
+ composable(route = Destination.Screen.ShareFileComposer.route) {
+ ComposerScreen(
+ actions.copy(
+ onCloseComposerClick = { activityActions.finishActivity() }
+ )
+ )
+ }
+ }
+}
+
+internal fun NavGraphBuilder.addSignOutAccountDialog(navController: NavHostController) {
+ dialog(route = Destination.Dialog.SignOut.route) {
+ SignOutAccountDialog(
+ userId = it.get(SignOutAccountDialog.USER_ID_KEY)?.takeIfNotBlank()?.let(::UserId),
+ actions = SignOutAccountDialog.Actions(
+ onSignedOut = { navController.navigateBack() },
+ onRemoved = { navController.navigateBack() },
+ onCancelled = { navController.navigateBack() }
+ )
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addSetMessagePassword(navController: NavHostController) {
+ composable(route = Destination.Screen.SetMessagePassword.route) {
+ SetMessagePasswordScreen(
+ onBackClick = {
+ navController.navigateBack()
+ }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addRemoveAccountDialog(navController: NavHostController) {
+ dialog(route = Destination.Dialog.RemoveAccount.route) {
+ RemoveAccountDialog(
+ userId = it.get(RemoveAccountDialog.USER_ID_KEY)?.takeIfNotBlank()?.let(::UserId),
+ onRemoved = { navController.navigateBack() },
+ onCancelled = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addSettings(navController: NavHostController) {
+ composable(route = Destination.Screen.Settings.route) {
+ MainSettingsScreen(
+ actions = MainSettingsScreen.Actions(
+ onAccountClick = {
+ navController.navigate(Destination.Screen.AccountSettings.route)
+ },
+ onThemeClick = {
+ navController.navigate(Destination.Screen.ThemeSettings.route)
+ },
+ onPushNotificationsClick = {
+ navController.navigate(Destination.Screen.Notifications.route)
+ },
+ onAutoLockClick = {
+ navController.navigate(Destination.Screen.AutoLockSettings.route)
+ },
+ onAlternativeRoutingClick = {
+ navController.navigate(Destination.Screen.AlternativeRoutingSettings.route)
+ },
+ onAppLanguageClick = {
+ navController.navigate(Destination.Screen.LanguageSettings.route)
+ },
+ onCombinedContactsClick = {
+ navController.navigate(Destination.Screen.CombinedContactsSettings.route)
+ },
+ onCustomizeToolbarClick = {
+ navController.navigate(Destination.Screen.CustomizeToolbar.route)
+ },
+ onSwipeActionsClick = {
+ navController.navigate(Destination.Screen.SwipeActionsSettings.route)
+ },
+ onClearCacheClick = {},
+ onExportLogsClick = { isInternalFeatureEnabled ->
+ if (isInternalFeatureEnabled) {
+ navController.navigate(Destination.Screen.ApplicationLogs.route)
+ } else {
+ navController.navigate(
+ Destination.Screen.ApplicationLogsView(ApplicationLogsViewItemMode.Events)
+ )
+ }
+ },
+ onBackClick = {
+ navController.navigateBack()
+ },
+ onSignOut = {
+ navController.navigate(Destination.Dialog.SignOut(it))
+ }
+ )
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addLabelList(
+ navController: NavHostController,
+ showLabelListErrorLoadingSnackbar: () -> Unit
+) {
+ composable(route = Destination.Screen.LabelList.route) {
+ LabelListScreen(
+ actions = LabelListScreen.Actions(
+ onBackClick = {
+ navController.navigateBack()
+ },
+ onLabelSelected = { labelId ->
+ navController.navigate(Destination.Screen.EditLabel(labelId))
+ },
+ onAddLabelClick = {
+ navController.navigate(Destination.Screen.CreateLabel.route)
+ },
+ showLabelListErrorLoadingSnackbar = showLabelListErrorLoadingSnackbar
+ )
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addLabelForm(
+ navController: NavHostController,
+ showLabelSavedSnackbar: () -> Unit,
+ showLabelDeletedSnackbar: () -> Unit,
+ showUpsellingSnackbar: (String) -> Unit,
+ showUpsellingErrorSnackbar: (String) -> Unit
+) {
+ val actions = LabelFormScreen.Actions.Empty.copy(
+ onBackClick = {
+ navController.navigateBack()
+ },
+ showLabelSavedSnackbar = showLabelSavedSnackbar,
+ showLabelDeletedSnackbar = showLabelDeletedSnackbar,
+ showUpsellingSnackbar = showUpsellingSnackbar,
+ showUpsellingErrorSnackbar = showUpsellingErrorSnackbar
+ )
+ composable(route = Destination.Screen.CreateLabel.route) { LabelFormScreen(actions) }
+ composable(route = Destination.Screen.EditLabel.route) { LabelFormScreen(actions) }
+}
+
+internal fun NavGraphBuilder.addFolderList(
+ navController: NavHostController,
+ showErrorSnackbar: (message: String) -> Unit
+) {
+ composable(route = Destination.Screen.FolderList.route) {
+ FolderListScreen(
+ actions = FolderListScreen.Actions(
+ onBackClick = {
+ navController.navigateBack()
+ },
+ onFolderSelected = { labelId ->
+ navController.navigate(Destination.Screen.EditFolder(labelId))
+ },
+ onAddFolderClick = {
+ navController.navigate(Destination.Screen.CreateFolder.route)
+ },
+ exitWithErrorMessage = { message ->
+ navController.navigateBack()
+ showErrorSnackbar(message)
+ }
+ )
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addFolderForm(
+ navController: NavHostController,
+ showSuccessSnackbar: (message: String) -> Unit,
+ showErrorSnackbar: (message: String) -> Unit,
+ showNormSnackbar: (String) -> Unit
+) {
+ val actions = FolderFormScreen.Actions.Empty.copy(
+ onBackClick = {
+ navController.navigateBack()
+ },
+ onFolderParentClick = { labelId, currentParentLabelId ->
+ navController.navigate(Destination.Screen.ParentFolderList(labelId, currentParentLabelId))
+ },
+ exitWithSuccessMessage = { message ->
+ navController.navigateBack()
+ showSuccessSnackbar(message)
+ },
+ exitWithErrorMessage = { message ->
+ navController.navigateBack()
+ showErrorSnackbar(message)
+ },
+ showUpsellingSnackbar = { showNormSnackbar(it) },
+ showUpsellingErrorSnackbar = { showErrorSnackbar(it) }
+ )
+ composable(route = Destination.Screen.CreateFolder.route) {
+ FolderFormScreen(
+ actions,
+ currentParentLabelId = navController.currentBackStackEntry?.savedStateHandle?.getLiveData(
+ SavedStateKey.CurrentParentFolderId.key
+ )?.observeAsState()
+ )
+ }
+ composable(route = Destination.Screen.EditFolder.route) {
+ FolderFormScreen(
+ actions,
+ currentParentLabelId = navController.currentBackStackEntry?.savedStateHandle?.getLiveData(
+ SavedStateKey.CurrentParentFolderId.key
+ )?.observeAsState()
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addParentFolderList(
+ navController: NavHostController,
+ showErrorSnackbar: (message: String) -> Unit
+) {
+ val actions = ParentFolderListScreen.Actions.Empty.copy(
+ onBackClick = {
+ navController.navigateBack()
+ },
+ onFolderSelected = { labelId ->
+ navController.previousBackStackEntry?.savedStateHandle?.set(
+ SavedStateKey.CurrentParentFolderId.key,
+ labelId.id
+ )
+ navController.navigateBack()
+ },
+ onNoneClick = {
+ navController.previousBackStackEntry?.savedStateHandle?.set(
+ SavedStateKey.CurrentParentFolderId.key,
+ ""
+ )
+ navController.navigateBack()
+ },
+ exitWithErrorMessage = { message ->
+ navController.navigateBack()
+ showErrorSnackbar(message)
+ }
+ )
+ composable(route = Destination.Screen.ParentFolderList.route) {
+ ParentFolderListScreen(actions)
+ }
+}
+
+internal fun NavGraphBuilder.addContacts(
+ navController: NavHostController,
+ showErrorSnackbar: (message: String) -> Unit,
+ showNormalSnackbar: (message: String) -> Unit,
+ showFeatureMissingSnackbar: () -> Unit
+) {
+ composable(route = Destination.Screen.Contacts.route) {
+ ContactListScreen(
+ listActions = ContactListScreen.Actions(
+ onNavigateToNewContactForm = {
+ navController.navigate(Destination.Screen.CreateContact.route)
+ },
+ onNavigateToNewGroupForm = {
+ navController.navigate(Destination.Screen.CreateContactGroup.route)
+ },
+ onNavigateToContactSearch = {
+ navController.navigate(Destination.Screen.ContactSearch.route)
+ },
+ openImportContact = {
+ showFeatureMissingSnackbar()
+ },
+ onContactSelected = { contactId ->
+ navController.navigate(Destination.Screen.ContactDetails(contactId))
+ },
+ onContactGroupSelected = { labelId ->
+ navController.navigate(Destination.Screen.ContactGroupDetails(labelId))
+ },
+ onBackClick = {
+ navController.navigateBack()
+ },
+ onSubscriptionUpgradeRequired = {
+ showNormalSnackbar(it)
+ },
+ onNewGroupClick = {
+ // Defined at the inner call site.
+ },
+ exitWithErrorMessage = { message ->
+ navController.navigateBack()
+ showErrorSnackbar(message)
+ }
+ )
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addContactDetails(
+ navController: NavHostController,
+ showSuccessSnackbar: (message: String) -> Unit,
+ showErrorSnackbar: (message: String) -> Unit,
+ showFeatureMissingSnackbar: () -> Unit
+) {
+ val actions = ContactDetailsScreen.Actions.Empty.copy(
+ onBackClick = { navController.navigateBack() },
+ exitWithSuccessMessage = { message ->
+ navController.navigateBack()
+ showSuccessSnackbar(message)
+ },
+ exitWithErrorMessage = { message ->
+ navController.navigateBack()
+ showErrorSnackbar(message)
+ },
+ onEditClick = { contactId ->
+ navController.navigate(Destination.Screen.EditContact(contactId))
+ },
+ showFeatureMissingSnackbar = { showFeatureMissingSnackbar() },
+ navigateToComposer = {
+ navController.navigate(Destination.Screen.MessageActionComposer(DraftAction.ComposeToAddresses(listOf(it))))
+ }
+ )
+ composable(route = Destination.Screen.ContactDetails.route) {
+ ContactDetailsScreen(actions)
+ }
+}
+
+internal fun NavGraphBuilder.addContactForm(
+ navController: NavHostController,
+ showSuccessSnackbar: (message: String) -> Unit,
+ showErrorSnackbar: (message: String) -> Unit
+) {
+
+ val actions = ContactFormScreen.Actions.Empty.copy(
+ onCloseClick = {
+ navController.navigateBack()
+ },
+ exitWithSuccessMessage = { message ->
+ navController.navigateBack()
+ showSuccessSnackbar(message)
+ },
+ exitWithErrorMessage = { message ->
+ navController.navigateBack()
+ showErrorSnackbar(message)
+ }
+ )
+ composable(route = Destination.Screen.CreateContact.route) {
+ ContactFormScreen(actions)
+ }
+ composable(route = Destination.Screen.EditContact.route) {
+ ContactFormScreen(actions)
+ }
+ composable(route = Destination.Screen.AddContact.route) {
+ ContactFormScreen(actions)
+ }
+}
+
+internal fun NavGraphBuilder.addContactGroupDetails(
+ navController: NavHostController,
+ showErrorSnackbar: (message: String) -> Unit,
+ showNormSnackbar: (message: String) -> Unit
+) {
+ val actions = ContactGroupDetailsScreen.Actions(
+ onBackClick = { navController.navigateBack() },
+ exitWithErrorMessage = { message ->
+ navController.navigateBack()
+ showErrorSnackbar(message)
+ },
+ exitWithNormMessage = { message ->
+ navController.navigateBack()
+ showNormSnackbar(message)
+ },
+ showErrorMessage = { message ->
+ showErrorSnackbar(message)
+ },
+ onEditClick = { labelId ->
+ navController.navigate(Destination.Screen.EditContactGroup(labelId))
+ },
+ navigateToComposer = { emails ->
+ navController.navigate(Destination.Screen.MessageActionComposer(DraftAction.ComposeToAddresses(emails)))
+ }
+ )
+ composable(route = Destination.Screen.ContactGroupDetails.route) {
+ ContactGroupDetailsScreen(actions)
+ }
+}
+
+internal fun NavGraphBuilder.addContactGroupForm(
+ navController: NavHostController,
+ showSuccessSnackbar: (message: String) -> Unit,
+ showErrorSnackbar: (message: String) -> Unit,
+ showNormSnackbar: (message: String) -> Unit
+) {
+ val actions = ContactGroupFormScreen.Actions(
+ onClose = { navController.navigateBack() },
+ exitWithErrorMessage = { message ->
+ navController.navigateBack()
+ showErrorSnackbar(message)
+ },
+ exitWithSuccessMessage = { message ->
+ navController.navigateBack()
+ showSuccessSnackbar(message)
+ },
+ manageMembers = { selectedContactEmailsIds ->
+ navController.currentBackStackEntry?.savedStateHandle?.set(
+ SavedStateKey.SelectedContactEmailIds.key,
+ selectedContactEmailsIds.map { it.id }
+ )
+ navController.navigate(Destination.Screen.ManageMembers.route)
+ },
+ exitToContactsWithNormMessage = { message ->
+ navController.popBackStack(Destination.Screen.Contacts.route, inclusive = false)
+ showNormSnackbar(message)
+ },
+ showErrorMessage = { message ->
+ showErrorSnackbar(message)
+ }
+ )
+ composable(route = Destination.Screen.CreateContactGroup.route) {
+ ContactGroupFormScreen(
+ actions,
+ selectedContactEmailsIds = navController.currentBackStackEntry?.savedStateHandle?.getLiveData>(
+ SavedStateKey.SelectedContactEmailIds.key
+ )?.observeAsState()
+ )
+ }
+ composable(route = Destination.Screen.EditContactGroup.route) {
+ ContactGroupFormScreen(
+ actions,
+ selectedContactEmailsIds = navController.currentBackStackEntry?.savedStateHandle?.getLiveData>(
+ SavedStateKey.SelectedContactEmailIds.key
+ )?.observeAsState()
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addManageMembers(
+ navController: NavHostController,
+ showErrorSnackbar: (message: String) -> Unit
+) {
+ val actions = ManageMembersScreen.Actions(
+ onDone = { selectedContactEmailsIds ->
+ navController.previousBackStackEntry?.savedStateHandle?.set(
+ SavedStateKey.SelectedContactEmailIds.key,
+ selectedContactEmailsIds.map { it.id }
+ )
+ navController.navigateBack()
+ },
+ onClose = { navController.navigateBack() },
+ exitWithErrorMessage = { message ->
+ navController.navigateBack()
+ showErrorSnackbar(message)
+ }
+ )
+ composable(route = Destination.Screen.ManageMembers.route) {
+ ManageMembersScreen(
+ actions,
+ selectedContactEmailsIds = navController
+ .previousBackStackEntry
+ ?.savedStateHandle
+ ?.getLiveData>(SavedStateKey.SelectedContactEmailIds.key)
+ ?.observeAsState()
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addContactSearch(navController: NavHostController) {
+ val actions = ContactSearchScreen.Actions(
+ onContactSelected = { contactId ->
+ navController.navigate(Destination.Screen.ContactDetails(contactId))
+ },
+ onContactGroupSelected = { labelId ->
+ navController.navigate(Destination.Screen.ContactGroupDetails(labelId))
+ },
+ onClose = { navController.navigateBack() }
+ )
+ composable(route = Destination.Screen.ContactSearch.route) {
+ ContactSearchScreen(
+ actions
+ )
+ }
+}
+
+fun NavGraphBuilder.addPostSubscription(onClose: () -> Unit) {
+ composable(route = Destination.Screen.PostSubscription.route) {
+ PostSubscriptionScreen(
+ onClose = onClose
+ )
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/route/OnboardingRoutes.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/route/OnboardingRoutes.kt
new file mode 100644
index 0000000000..136f5aad2a
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/route/OnboardingRoutes.kt
@@ -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 .
+ */
+
+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
+ )
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/route/SettingsRoutes.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/route/SettingsRoutes.kt
new file mode 100644
index 0000000000..9954e1cc5c
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/route/SettingsRoutes.kt
@@ -0,0 +1,240 @@
+/*
+ * 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 .
+ */
+
+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.LockScreenActivity
+import ch.protonmail.android.MainActivity
+import ch.protonmail.android.mailbugreport.presentation.ui.ApplicationLogsPeekView
+import ch.protonmail.android.mailbugreport.presentation.ui.ApplicationLogsScreen
+import ch.protonmail.android.mailcommon.presentation.extension.navigateBack
+import ch.protonmail.android.mailsettings.domain.model.SwipeActionDirection
+import ch.protonmail.android.mailsettings.presentation.accountsettings.AccountSettingScreen
+import ch.protonmail.android.mailsettings.presentation.accountsettings.autodelete.AutoDeleteSettingScreen
+import ch.protonmail.android.mailsettings.presentation.accountsettings.conversationmode.ConversationModeSettingScreen
+import ch.protonmail.android.mailsettings.presentation.accountsettings.defaultaddress.ui.EditDefaultAddressScreen
+import ch.protonmail.android.mailsettings.presentation.accountsettings.identity.ui.EditAddressIdentityScreen
+import ch.protonmail.android.mailsettings.presentation.settings.alternativerouting.AlternativeRoutingSettingScreen
+import ch.protonmail.android.mailsettings.presentation.settings.autolock.ui.AutoLockSettingsScreen
+import ch.protonmail.android.mailsettings.presentation.settings.autolock.ui.pin.AutoLockPinScreen
+import ch.protonmail.android.mailsettings.presentation.settings.combinedcontacts.CombinedContactsSettingScreen
+import ch.protonmail.android.mailsettings.presentation.settings.customizetoolbar.CustomizeToolbarScreen
+import ch.protonmail.android.mailsettings.presentation.settings.language.LanguageSettingsScreen
+import ch.protonmail.android.mailsettings.presentation.settings.notifications.ui.PushNotificationsSettingsScreen
+import ch.protonmail.android.mailsettings.presentation.settings.privacy.PrivacySettingsScreen
+import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.EditSwipeActionPreferenceScreen
+import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.EditSwipeActionPreferenceScreen.SWIPE_DIRECTION_KEY
+import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.SwipeActionsPreferenceScreen
+import ch.protonmail.android.mailsettings.presentation.settings.theme.ThemeSettingsScreen
+import ch.protonmail.android.navigation.Launcher
+import ch.protonmail.android.navigation.model.Destination.Screen
+import me.proton.core.compose.navigation.require
+
+fun NavGraphBuilder.addAccountSettings(
+ navController: NavHostController,
+ launcherActions: Launcher.Actions,
+ activityActions: MainActivity.Actions
+) {
+ composable(route = Screen.AccountSettings.route) {
+ AccountSettingScreen(
+ actions = AccountSettingScreen.Actions(
+ onBackClick = { navController.navigateBack() },
+ onPasswordManagementClick = launcherActions.onPasswordManagement,
+ onRecoveryEmailClick = launcherActions.onRecoveryEmail,
+ onSecurityKeysClick = activityActions.openSecurityKeys,
+ onConversationModeClick = { navController.navigate(Screen.ConversationModeSettings.route) },
+ onDefaultEmailAddressClick = { navController.navigate(Screen.DefaultEmailSettings.route) },
+ onDisplayNameClick = { navController.navigate(Screen.DisplayNameSettings.route) },
+ onPrivacyClick = { navController.navigate(Screen.PrivacySettings.route) },
+ onLabelsClick = { navController.navigate(Screen.LabelList.route) },
+ onFoldersClick = { navController.navigate(Screen.FolderList.route) },
+ onAutoDeleteClick = { navController.navigate(Screen.AutoDeleteSettings.route) }
+ )
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addAlternativeRoutingSetting(navController: NavHostController) {
+ composable(route = Screen.AlternativeRoutingSettings.route) {
+ AlternativeRoutingSettingScreen(
+ modifier = Modifier,
+ onBackClick = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addCombinedContactsSetting(navController: NavHostController) {
+ composable(route = Screen.CombinedContactsSettings.route) {
+ CombinedContactsSettingScreen(
+ modifier = Modifier,
+ onBackClick = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addConversationModeSettings(navController: NavHostController) {
+ composable(route = Screen.ConversationModeSettings.route) {
+ ConversationModeSettingScreen(
+ modifier = Modifier,
+ onBackClick = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addAutoDeleteSettings(navController: NavHostController) {
+ composable(route = Screen.AutoDeleteSettings.route) {
+ AutoDeleteSettingScreen(
+ modifier = Modifier,
+ onBackClick = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addDefaultEmailSettings(navController: NavHostController) {
+ composable(route = Screen.DefaultEmailSettings.route) {
+ EditDefaultAddressScreen(
+ modifier = Modifier,
+ onBackClick = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addDisplayNameSettings(navController: NavHostController) {
+ composable(route = Screen.DisplayNameSettings.route) {
+ EditAddressIdentityScreen(
+ modifier = Modifier,
+ onBackClick = { navController.navigateBack() },
+ onCloseScreen = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addPrivacySettings(navController: NavHostController) {
+ composable(route = Screen.PrivacySettings.route) {
+ PrivacySettingsScreen(
+ modifier = Modifier,
+ onBackClick = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addAutoLockSettings(navController: NavHostController) {
+ composable(route = Screen.AutoLockSettings.route) {
+ AutoLockSettingsScreen(
+ modifier = Modifier,
+ onBackClick = { navController.navigateBack() },
+ onPinScreenNavigation = { navController.navigate(Screen.AutoLockPinScreen(it)) }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addAutoLockPinScreen(
+ activityActions: LockScreenActivity.Actions,
+ onBack: () -> Unit,
+ onShowSuccessSnackbar: (String) -> Unit
+) {
+ composable(route = Screen.AutoLockPinScreen.route) {
+ AutoLockPinScreen(
+ modifier = Modifier,
+ onBackClick = onBack,
+ onShowSuccessSnackbar = onShowSuccessSnackbar,
+ onBiometricsClick = activityActions.showBiometricPrompt
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addEditSwipeActionsSettings(navController: NavHostController) {
+ composable(route = Screen.EditSwipeActionSettings.route) {
+ EditSwipeActionPreferenceScreen(
+ modifier = Modifier,
+ direction = SwipeActionDirection(it.require(SWIPE_DIRECTION_KEY)),
+ onBack = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addLanguageSettings(navController: NavHostController) {
+ composable(route = Screen.LanguageSettings.route) {
+ LanguageSettingsScreen(
+ modifier = Modifier,
+ onBackClick = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addCustomizeToolbar(navController: NavHostController) {
+ composable(route = Screen.CustomizeToolbar.route) {
+ CustomizeToolbarScreen(
+ modifier = Modifier,
+ onBackClick = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addSwipeActionsSettings(navController: NavHostController) {
+ composable(route = Screen.SwipeActionsSettings.route) {
+ SwipeActionsPreferenceScreen(
+ modifier = Modifier,
+ actions = SwipeActionsPreferenceScreen.Actions(
+ onBackClick = { navController.navigateBack() },
+ onChangeSwipeLeftClick = {
+ navController.navigate(Screen.EditSwipeActionSettings(SwipeActionDirection.LEFT))
+ },
+ onChangeSwipeRightClick = {
+ navController.navigate(Screen.EditSwipeActionSettings(SwipeActionDirection.RIGHT))
+ }
+ )
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addThemeSettings(navController: NavHostController) {
+ composable(route = Screen.ThemeSettings.route) {
+ ThemeSettingsScreen(
+ modifier = Modifier,
+ onBackClick = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addNotificationsSettings(navController: NavHostController) {
+ composable(route = Screen.Notifications.route) {
+ PushNotificationsSettingsScreen(
+ modifier = Modifier,
+ onBackClick = { navController.navigateBack() }
+ )
+ }
+}
+
+internal fun NavGraphBuilder.addExportLogsSettings(navController: NavHostController) {
+ composable(route = Screen.ApplicationLogs.route) {
+ ApplicationLogsScreen(
+ onBackClick = { navController.navigateBack() },
+ onViewItemClick = { navController.navigate(Screen.ApplicationLogsView(it)) }
+ )
+ }
+ composable(route = Screen.ApplicationLogsView.route) {
+ ApplicationLogsPeekView(
+ onBack = { navController.navigateBack() }
+ )
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/route/UpsellingRoutes.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/route/UpsellingRoutes.kt
new file mode 100644
index 0000000000..be347795bc
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/route/UpsellingRoutes.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.navigation.route
+
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import ch.protonmail.android.mailupselling.domain.model.UpsellingEntryPoint
+import ch.protonmail.android.mailupselling.presentation.ui.screen.UpsellingScreen
+import ch.protonmail.android.navigation.model.Destination
+
+fun NavGraphBuilder.addUpsellingRoutes(actions: UpsellingScreen.Actions) {
+ composable(route = Destination.Screen.Upselling.StandaloneMailbox.route) {
+ UpsellingScreen(
+ bottomSheetActions = actions,
+ entryPoint = UpsellingEntryPoint.Feature.Mailbox
+ )
+ }
+ composable(route = Destination.Screen.Upselling.StandaloneMailboxPromo.route) {
+ UpsellingScreen(
+ bottomSheetActions = actions,
+ entryPoint = UpsellingEntryPoint.Feature.MailboxPromo
+ )
+ }
+ composable(route = Destination.Screen.Upselling.StandaloneNavbar.route) {
+ UpsellingScreen(
+ bottomSheetActions = actions,
+ entryPoint = UpsellingEntryPoint.Feature.Navbar
+ )
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/share/ShareIntentObserver.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/share/ShareIntentObserver.kt
new file mode 100644
index 0000000000..9cbb17ee04
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/navigation/share/ShareIntentObserver.kt
@@ -0,0 +1,58 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation.share
+
+import android.content.Intent
+import ch.protonmail.android.mailnotifications.domain.NotificationsDeepLinkHelper
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterNotNull
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ShareIntentObserver @Inject constructor() {
+
+ private val _intentFlow = MutableStateFlow(null)
+ private val intentFlow: StateFlow = _intentFlow
+
+ operator fun invoke(): Flow {
+ return intentFlow
+ .filterNotNull()
+ .filter { intent ->
+ !intent.isNotificationIntent() && intent.action in setOf(
+ Intent.ACTION_SEND,
+ Intent.ACTION_SEND_MULTIPLE,
+ Intent.ACTION_VIEW,
+ Intent.ACTION_SENDTO,
+ Intent.ACTION_MAIN
+ )
+ }
+ .distinctUntilChanged()
+ }
+
+ fun onNewIntent(intent: Intent?) {
+ _intentFlow.value = intent
+ }
+
+ private fun Intent.isNotificationIntent(): Boolean = data?.host == NotificationsDeepLinkHelper.NotificationHost
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/useragent/BuildUserAgent.kt b/app/src/main/kotlin/ch/protonmail/android/useragent/BuildUserAgent.kt
new file mode 100644
index 0000000000..516ed1573c
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/useragent/BuildUserAgent.kt
@@ -0,0 +1,37 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.useragent
+
+import javax.inject.Inject
+
+class BuildUserAgent @Inject constructor(
+ val getAppVersion: GetAppVersion,
+ val getAndroidVersion: GetAndroidVersion,
+ val getDeviceData: GetDeviceData
+) {
+ operator fun invoke(): String {
+ val device = getDeviceData()
+
+ val protonMailAppVersion = "ProtonMail/${getAppVersion()}"
+ val deviceSpecs = "${device.brand} ${device.model}"
+ val androidInfo = "Android ${getAndroidVersion()}; $deviceSpecs"
+
+ return "$protonMailAppVersion ($androidInfo)"
+ }
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/useragent/GetAndroidVersion.kt b/app/src/main/kotlin/ch/protonmail/android/useragent/GetAndroidVersion.kt
new file mode 100644
index 0000000000..4df4dc9e15
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/useragent/GetAndroidVersion.kt
@@ -0,0 +1,26 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.useragent
+
+import android.os.Build.VERSION
+import javax.inject.Inject
+
+class GetAndroidVersion @Inject constructor() {
+ operator fun invoke(): String = VERSION.RELEASE
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/useragent/GetAppVersion.kt b/app/src/main/kotlin/ch/protonmail/android/useragent/GetAppVersion.kt
new file mode 100644
index 0000000000..7045354825
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/useragent/GetAppVersion.kt
@@ -0,0 +1,26 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.useragent
+
+import ch.protonmail.android.BuildConfig
+import javax.inject.Inject
+
+class GetAppVersion @Inject constructor() {
+ operator fun invoke(): String = BuildConfig.VERSION_NAME
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/useragent/GetDeviceData.kt b/app/src/main/kotlin/ch/protonmail/android/useragent/GetDeviceData.kt
new file mode 100644
index 0000000000..3c47a757ce
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/useragent/GetDeviceData.kt
@@ -0,0 +1,31 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.useragent
+
+import android.os.Build
+import ch.protonmail.android.useragent.model.DeviceData
+import javax.inject.Inject
+
+class GetDeviceData @Inject constructor() {
+ operator fun invoke() = DeviceData(
+ Build.DEVICE,
+ Build.BRAND,
+ Build.MODEL
+ )
+}
diff --git a/app/src/main/kotlin/ch/protonmail/android/useragent/model/DeviceData.kt b/app/src/main/kotlin/ch/protonmail/android/useragent/model/DeviceData.kt
new file mode 100644
index 0000000000..12b5f13e83
--- /dev/null
+++ b/app/src/main/kotlin/ch/protonmail/android/useragent/model/DeviceData.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.useragent.model
+
+data class DeviceData(
+ val device: String,
+ val brand: String,
+ val model: String
+)
diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
index 2b068d1146..9889a97ad9 100644
--- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -1,30 +1,77 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index 07d5da9cbf..4f7f81cdbc 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -1,170 +1,27 @@
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 0000000000..5cce474a85
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
deleted file mode 100644
index 4fc244418b..0000000000
--- a/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index eca70cfe52..48305c504a 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,5 +1,24 @@
+
+
-
-
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index eca70cfe52..48305c504a 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,5 +1,24 @@
+
+
-
-
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
deleted file mode 100644
index c209e78ecd..0000000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
deleted file mode 100644
index b2dfe3d1ba..0000000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
deleted file mode 100644
index 4f0f1d64e5..0000000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
deleted file mode 100644
index 62b611da08..0000000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
deleted file mode 100644
index 948a3070fe..0000000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
deleted file mode 100644
index 1b9a6956b3..0000000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
deleted file mode 100644
index 28d4b77f9f..0000000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9287f50836..0000000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
deleted file mode 100644
index aa7d6427e6..0000000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9126ae37cb..0000000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml
new file mode 100644
index 0000000000..59846606e0
--- /dev/null
+++ b/app/src/main/res/values-b+es+419/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Cerrar sesión
+ ¿Está seguro de que quiere la cerrar sesión?
+ Sí
+ No
+ Quitar la cuenta
+ Cerrará la sesión y eliminará toda la información asociada con esta cuenta.
+ Quitar
+ Cancelar
+ Seleccione otra cuenta
+ No está conectado.
+ Próximamente...
+ Borrador guardado
+ Descartar
+ Enviando mensaje…
+ Sin conexión, mensaje puesto en cola de envío
+ Mensaje enviado
+ Error al enviar el mensaje
+ Ir a Borradores
+ Error al cargar el archivo adjunto
+ Se cambió a la cuenta %s
+ Etiqueta guardada
+ Etiqueta eliminada
+ Error al cargar la etiqueta. Intente de nuevo más tarde.
+ Aplicación bloqueada
+ Use el código PIN en su lugar.
+
diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml
new file mode 100644
index 0000000000..0a0ce5f764
--- /dev/null
+++ b/app/src/main/res/values-be/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Выхад
+ Вы сапраўды хочаце выйсці?
+ Так
+ Не
+ Выдаліць уліковы запіс
+ Адбудзецца выхад і выдаленне ўсёй інфармацыі, якая звязана з гэтым уліковым запісам.
+ Выдаліць
+ Скасаваць
+ Выбраць іншы ўліковы запіс
+ Вы па-за сеткай
+ Функцыя неўзабаве з\'явіцца…
+ Чарнавік захаваны
+ Адхіліць
+ Адпраўка паведамлення…
+ Вы па-за сеткай, паведамленне ў чарзе на адпраўку
+ Паведамленне адпраўлена
+ Памылка адпраўкі паведамлення
+ Перайсці ў Чарнавікі
+ Памылка запампоўвання далучэння
+ Зменена на ўліковы запіс: %s
+ Метка захавана
+ Метка выдалена
+ Памылка загрузкі меткі. Паспрабуйце пазней
+ Праграма заблакіравана
+ Выкарыстаць замест гэтага PIN
+
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
new file mode 100644
index 0000000000..10c80030cd
--- /dev/null
+++ b/app/src/main/res/values-ca/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Tanca la sessió
+ Segur que desitgeu tancar la sessió?
+ Sí
+ No
+ Esborra el compte
+ Es tancarà la sessió i s\'esborrarà tota la informació associada amb aquest compte.
+ Esborra
+ Cancel·la
+ Seleccioneu un altre compte
+ Esteu fora de línia
+ Aquesta característica arribarà aviat…
+ S\'ha desat l\'esborrany.
+ Descarta
+ Enviant missatge…
+ Fora de línia, missatge a la cua per enviar
+ S\'ha enviat el missatge.
+ Error enviant el missatge
+ Aneu a Esborranys
+ S\'ha produït un error en carregar l\'adjunt.
+ S\'ha canviat al compte %s
+ S\'ha desat l\'etiqueta.
+ S\'ha eliminat l\'etiqueta.
+ S\'ha produït un error en carregar l\'etiqueta. Proveu de nou més tard.
+ L\'aplicació està bloquejada
+ Utilitzeu el PIN en lloc
+
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
new file mode 100644
index 0000000000..6a959cc925
--- /dev/null
+++ b/app/src/main/res/values-cs/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Odhlásit se
+ Opravdu se chcete odhlásit?
+ Ano
+ Ne
+ Odebrat účet
+ Odhlásíte se a odstraníte všechny informace spojené s tímto účtem.
+ Odebrat
+ Zrušit
+ Vybrat jiný účet
+ Jste v režimu offline
+ Funkce bude brzy k dispozici…
+ Koncept uložen
+ Zahodit
+ Odesílání zprávy…
+ Offline, zpráva je ve frontě k odeslání
+ Zpráva odeslána
+ Chyba při odesílání zprávy
+ Přejít do Konceptů
+ Chyba při nahrávání přílohy
+ Přepnuto na účet: %s
+ Štítek uložen
+ Štítek smazán
+ Chyba při načítání štítku, zkuste to prosím později
+ Aplikace uzamčena
+ Použít raději PIN
+
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml
new file mode 100644
index 0000000000..9b9e56b7a4
--- /dev/null
+++ b/app/src/main/res/values-da/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Log ud
+ Er du sikker på, at du vil logge ud?
+ Ja
+ Nej
+ Fjern konto
+ Du logger ud og fjerner alle tilknyttede oplysninger til denne konto.
+ Fjern
+ Annuller
+ Vælg en anden konto
+ Du er offline
+ Funktion kommer snart…
+ Kladde gemt
+ Kassér
+ Sender besked…
+ Offline, besked er i kø til afsendelse
+ Besked sendt
+ Besked om fejlafsendelse
+ Gå til kladder
+ Fejl ved upload af vedhæftning
+ Skiftede til konto: %s
+ Etiket gemt
+ Etiket slettet
+ Indlæsningsfejl af etikette. Prøv igen senere.
+ App er låst
+ Brug i stedet pinkode
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000000..a2b8467766
--- /dev/null
+++ b/app/src/main/res/values-de/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Abmelden
+ Bist du sicher, dass du dich abmelden möchtest?
+ Ja
+ Nein
+ Konto entfernen
+ Du wirst abgemeldet und alle Informationen, die mit diesem Konto verbunden sind, werden gelöscht.
+ Entfernen
+ Abbrechen
+ Anderes Konto auswählen
+ Du bist offline
+ Funktion demnächst verfügbar…
+ Entwurf gespeichert
+ Verwerfen
+ Nachricht wird gesendet…
+ Offline, die Nachricht ist in der Warteschlange für den Versand.
+ Nachricht gesendet
+ Fehler beim Senden der Nachricht
+ Zu den Entwürfen
+ Fehler beim Hochladen des Anhangs.
+ Zu Konto „%s“ gewechselt
+ Kategorie gespeichert
+ Kategorie gelöscht
+ Fehler beim Laden der Kategorie. Bitte versuche es später erneut.
+ App gesperrt
+ Stattdessen PIN verwenden
+
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
new file mode 100644
index 0000000000..94b1b50f73
--- /dev/null
+++ b/app/src/main/res/values-el/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Αποσύνδεση
+ Θέλετε σίγουρα να αποσυνδεθείτε;
+ Ναι
+ Όχι
+ Αφαίρεση Λογαριασμού
+ Πρόκειται να αποσυνδεθείτε και να αφαιρέσετε όλες τις πληροφορίες που σχετίζονται με αυτόν τον λογαριασμό.
+ Αφαίρεση
+ Ακύρωση
+ Επιλογή άλλου λογαριασμού
+ Βρίσκεστε εκτός σύνδεσης
+ Η δυνατότητα θα είναι διαθέσιμη σύντομα…
+ Το πρόχειρο αποθηκεύτηκε
+ Απόρριψη
+ Γίνεται αποστολή μηνύματος…
+ Εκτός σύνδεσης, το μήνυμα τέθηκε σε αναμονή για αποστολή
+ Το μήνυμα στάλθηκε
+ Σφάλμα κατά την αποστολή του μηνύματος
+ Μετάβαση στα Πρόχειρα
+ Σφάλμα κατά τη μεταφόρτωση συνημμένου
+ Έγινε αλλαγή σε λογαριασμό: %s
+ Η ετικέτα αποθηκεύτηκε
+ Η ετικέτα διαγράφηκε
+ Σφάλμα φόρτωσης ετικέτας, παρακαλούμε δοκιμάστε ξανά αργότερα
+ Η εφαρμογή είναι κλειδωμένη
+ Εναλλακτικά, χρησιμοποιήστε PIN
+
diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml
new file mode 100644
index 0000000000..f4909d81d3
--- /dev/null
+++ b/app/src/main/res/values-es-rES/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Cerrar sesión
+ ¿Estás seguro de que quieres cerrar la sesión?
+ Sí
+ No
+ Quitar la cuenta
+ Cerrarás la sesión y eliminarás toda la información asociada con esta cuenta.
+ Quitar
+ Cancelar
+ Selecciona otra cuenta
+ No estás conectado.
+ Funcionalidad disponible próximamente…
+ Se ha guardado el borrador.
+ Descartar
+ Enviando el mensaje…
+ Sin conexión: El mensaje se ha añadido a la cola de envío.
+ Se ha enviado el mensaje.
+ Error al enviar el mensaje
+ Ir a la carpeta de borradores
+ Error al cargar el archivo adjunto
+ Se ha cambiado a la cuenta: %s
+ Se ha guardado la etiqueta.
+ Se ha eliminado la etiqueta.
+ Error al cargar la etiqueta. Intenta de nuevo más tarde.
+ Aplicación bloqueada
+ Usa el código PIN en su lugar.
+
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
new file mode 100644
index 0000000000..011a4aeac2
--- /dev/null
+++ b/app/src/main/res/values-fi/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Kirjaudu ulos
+ Haluatko varmasti kirjautua ulos?
+ Kyllä
+ En
+ Poista tili
+ Kirjaudut ulos ja kaikki tiliin liittyvät tiedot poistetaan.
+ Poista
+ Peru
+ Valitse toinen tili
+ Ei yhteyttä
+ Ominaisuus on tulossa pian…
+ Luonnos tallennettiin
+ Hylkää
+ Lähetetään viestiä…
+ Ei yhteyttä. Viesti merkittiin lähetettäväksi myöhemmin.
+ Viesti lähetettiin
+ Virhe lähetettäessä viestiä
+ Avaa luonnokset
+ Virhe lisättäessä liitettä
+ Vaihdettiin tiliin %s
+ Tunniste tallennettiin
+ Tunniste poistettiin
+ Virhe ladattaessa tunnistetta. Yritä myöhemmin uudelleen.
+ Sovellus on lukittu
+ Käytä sen sijaan PIN-koodia
+
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
new file mode 100644
index 0000000000..2ad0c42bad
--- /dev/null
+++ b/app/src/main/res/values-fr/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Déconnexion
+ Voulez-vous vraiment vous déconnecter ?
+ Oui
+ Non
+ Retirer le compte
+ Vous allez vous déconnecter et retirer toutes les informations associées à ce compte.
+ Retirer
+ Annuler
+ Sélectionner un autre compte
+ Vous êtes hors ligne.
+ Fonctionnalité bientôt disponible…
+ Le brouillon a été enregistré.
+ Abandonner
+ L\'envoi du message est en cours.
+ Vous êtes hors ligne, le message est en file d\'attente pour l\'envoi.
+ Le message a été envoyé.
+ Une erreur s\'est produite lors de l\'envoi du message.
+ Déplacer vers le dossier Brouillons
+ La pièce jointe n\'a pas pu être chargée.
+ Basculé sur le compte %s
+ Le label a été enregistré.
+ Le label a été supprimé.
+ Une erreur s\'est produite lors du chargement du label. Veuillez réessayer plus tard.
+ Application verrouillée
+ Utiliser le code PIN à la place
+
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
new file mode 100644
index 0000000000..04d840007e
--- /dev/null
+++ b/app/src/main/res/values-hi/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ साइनआउट करें
+ पक्का साइनआउट करें?
+ हां
+ नहीं
+ खाता हटाएं
+ आप साइन आउट करेंगे और इस खाते से जुड़ी सभी जानकारी हटा देंगे।
+ हटाएं
+ कैंसिल करें
+ दूसरा खाता चुनें
+ आप ऑफलाइन हैं
+ सुविधा जल्द शुरू होगी…
+ ड्राफ़्ट सेव किया गया
+ रद्द करें
+ संदेश भेजा जा रहा है…
+ आप ऑफ़लाइन हैं, ऑनलाइन होने पर संदेश भेजा जाएगा
+ संदेश भेजा गया
+ संदेश भेजने में समस्या आई
+ ड्राफ्ट पर जाएं
+ अटैचमेंट अपलोड करने में समस्या आई
+ इस खाते पर स्विच किया गया: %s
+ लेबल सहेजा गया
+ लेबल मिटाया गया
+ लेबल लोड करते समय एरर, कृपया बाद में वापस कोशिश करें
+ ऐप लॉक हो गया
+ इसके जगह पिन इस्तेमाल करें
+
diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml
new file mode 100644
index 0000000000..c476bc7141
--- /dev/null
+++ b/app/src/main/res/values-hr/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Odjavite se
+ Da li ste sigurni da se želite odjaviti?
+ Da
+ Ne
+ Ukloni račun
+ Odjavit ćete se i ukloniti sve podatke povezane s ovim računom.
+ Ukloni
+ Poništi
+ Odaberite drugi račun
+ Niste na mreži
+ Značajka uskoro dolazi...
+ Skica je spremljena
+ Odbaci
+ Slanje poruke…
+ Izvan mreže, poruka čeka na slanje
+ Poruka je poslana
+ Pogreška pri slanju poruke
+ Idi u skice
+ Error uploading attachment
+ Prebačeno na račun %s
+ Oznaka spremljena
+ Oznaka izbrisana
+ Pogreška pri učitavanju oznake, pokušajte ponovno kasnije
+ Aplikacija je zaključana
+ Koristi PIN
+
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
new file mode 100644
index 0000000000..ca86b7c914
--- /dev/null
+++ b/app/src/main/res/values-hu/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Kijelentkezés
+ Biztos, hogy ki szeretne jelentkezni?
+ Igen
+ Nem
+ Fiók eltávolítása
+ Kijelentkezik és eltávolítja a fiókkal kapcsolatos összes adatot.
+ Eltávolítás
+ Mégsem
+ Másik fiók választása
+ Nincs internetkapcsolat
+ A funkció hamarosan érkezik...
+ Piszkozat mentve
+ Elvetés
+ Üzenet küldése…
+ Offline, az üzenet sorba van állítva küldésre
+ Üzenet elküldve
+ Hiba az üzenet küldése közben
+ Ugrás a piszkozatokhoz
+ Hiba mellékletek feltöltésénél
+ Fiókváltás történt erre: %s
+ Címke mentve
+ Címke törölve
+ Hiba a címke betöltése során, próbálja újra később
+ Az alkalmazás zárolt
+ Inkább PIN-kód használata
+
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
new file mode 100644
index 0000000000..68dfa3dd4b
--- /dev/null
+++ b/app/src/main/res/values-in/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Keluar
+ Apakah Anda yakin ingin keluar?
+ Ya
+ Tidak
+ Hapus Akun
+ Anda akan keluar dari akun dan menghapus segala informasi yang terhubung dengan akun ini.
+ Hapus
+ Batal
+ Pilih akun lain
+ Anda sedang offline
+ Fitur ini akan segera hadir…
+ Draf disimpan
+ Buang
+ Mengirim pesan…
+ Luring, pesan diantrekan untuk pengiriman
+ Pesan terkirim
+ Masalah dalam mengirim pesan
+ Ke Draf
+ Kesalahan mengunggah lampiran
+ Anda telah beralih ke akun: \'%s
+ Label disimpan
+ Label dihapus
+ Terdapat kesalahan dalam memuat label, silakan coba lagi nanti
+ Aplikasi terkunci
+ Gunakan PIN sebagai gantinya
+
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
new file mode 100644
index 0000000000..d615951fd1
--- /dev/null
+++ b/app/src/main/res/values-it/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Esci dall\'account
+ Sei sicuro di voler uscire da questo account?
+ Esci
+ Annulla
+ Rimuovi l\'account
+ Sei sicuro di voler rimuovere questo account e tutte le informazioni associate a esso?
+ Rimuovi
+ Annulla
+ Seleziona un altro account
+ Nessuna connessione a Internet
+ Funzionalità in arrivo…
+ Bozza salvata
+ Scarta
+ Invio del messaggio in corso
+ Nessuna connessione a Internet. Invio del messaggio sospeso.
+ Messaggio inviato
+ Invio del messaggio non riuscito
+ Vai alle bozze
+ Caricamento dell\'allegato non riuscito
+ Account attuale: %s
+ Etichetta salvata
+ Etichetta eliminata
+ Caricamento dell\'etichetta non riuscito
+ App bloccata
+ Usa invece il PIN
+
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
new file mode 100644
index 0000000000..39199d1cce
--- /dev/null
+++ b/app/src/main/res/values-ja/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ サインアウト
+ 本当にサインアウトしますか?
+ はい
+ いいえ
+ アカウントの削除
+ サインアウトして、このアカウントに関連するすべての情報を削除することになります。
+ 削除
+ キャンセル
+ 別のアカウントを選択
+ オフラインです
+ 近日公開予定です。
+ 下書きを保存しました
+ 破棄
+ メッセージを送信中…
+ オフライン、メッセージは送信待ち
+ メッセージが送信されました
+ メッセージ送信エラー
+ 下書きを開く
+ 添付ファイルのアップロードエラー
+ アカウントを %s に切り替えました
+ ラベルが保存されました
+ ラベルが削除されました
+ ラベルの読み込み中にエラーが発生しました。後でもう一度お試しください。
+ アプリがロックされました
+ 代わりにPINコードを使用する
+
diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml
new file mode 100644
index 0000000000..b6cac1bc73
--- /dev/null
+++ b/app/src/main/res/values-ka/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ გასვლა
+ დარწმუნებული ბრძანდებით, რომ გნებავთ, გახვიდეთ?
+ დიახ
+ არა
+ ანგარიშის წაშლა
+ გახვალთ და წაშლით ყველაფერს, რაც დაკავშირებულია ამ ანგარიშთან.
+ წაშლა
+ გაუქმება
+ აირჩიეთ სხვა ანგარიში
+ ქსელი გამორთულია
+ ფუნქცია მალე ჩაირთვება…
+ მონახაზი შენახულია
+ მოცილება
+ შეტყობინების გაგზავნა…
+ ინტერნეტის გარეშე. შეტყობინება გაგზავნის რიგშია
+ შეტყობინება გაიგზავნა
+ შეტყობინების გაგზავნის შეცდომა
+ მონახაზებზე გადასვლა
+ მიმაგრებული ფაილის ატვირთვის შეცდომა
+ გადაერთეთ ანგარიშზე: %s
+ ჭდე შენახულია
+ ჭდე წაშლილია
+ ჭდის ჩატვირთვის შეცდომა. მოგვიანებით სცადეთ
+ აპლიკაცია ჩაკეტილია
+ უმჯობესია გამოიყენოთ PIN
+
diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml
new file mode 100644
index 0000000000..2ee3fa5f66
--- /dev/null
+++ b/app/src/main/res/values-kab/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Asenser
+ Titedt tebɣiḍ ad teffɣeḍ?
+ Ih
+ Ala
+ Kkes amiḍan
+ Ad teffɣeḍ syen kkes talɣut akk yeqqnen ɣer umiḍan-a.
+ Kkes
+ Sefsex
+ Fren amiḍan-nniḍen
+ Ur teqqineḍ ara
+ Tamahilt-a ad tili ticki…
+ Arewway yettusekles
+ Sefsex
+ Tuzna n yizen…
+ Offline, message queued for sending
+ Izen yettwazen
+ Yalla-d tuccḍa deg tuzna n yizen
+ Ddu ɣer Yirewwayen
+ Error uploading attachment
+ Tnekzeḍ ɣer umiḍan: %s
+ Label saved
+ Tabzimt tettwakkes
+ Error loading label, please try again later
+ Asnas yesekkeṛ
+ Seqdec PIN deg umḍiq
+
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
new file mode 100644
index 0000000000..94c09bb5b3
--- /dev/null
+++ b/app/src/main/res/values-ko/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ 로그아웃
+ 정말로 로그아웃하시겠습니까?
+ 예
+ 아니오
+ 계정 삭제
+ 계정에서 로그아웃되며 계정과 관련된 모든 데이터가 삭제됩니다.
+ 제거
+ 취소
+ 다른 계정 선택
+ 연결되지 않음
+ 곧 출시될 기능입니다…
+ 임시 저장됨
+ 저장 안 함
+ 메시지를 보내는중…
+ 오프라인, 메시지 전송 대기중
+ 메시지 보냄
+ 메시지 전송 오류
+ 임시 보관함으로 이동하기
+ 첨부 파일 업로드 중 오류
+ 계정 전환됨: %s
+ 라벨을 저장했습니다.
+ 라벨을 삭제했습니다.
+ 라벨 로드 중 오류 발생, 다시 시도하십시오.
+ 앱 잠김
+ 대신 PIN 사용하기
+
diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml
new file mode 100644
index 0000000000..50320156ea
--- /dev/null
+++ b/app/src/main/res/values-nb-rNO/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Logg av
+ Er du sikker på at du vil logge av?
+ Ja
+ Nei
+ Fjern konto
+ Du vil logges av og all informasjon forbundet med denne kontoen vil fjernes.
+ Fjern
+ Avbryt
+ Velg en annen konto
+ Du er frakoblet
+ Denne funksjonen kommer snart ...
+ Utkast lagret
+ Forkast
+ Sender melding…
+ Frakoblet modus, melding satt i sendekø
+ Melding sendt
+ Feil ved sending av melding
+ Gå til utkast
+ Feil ved opplasting av vedlegg
+ Byttet til konto %s
+ Etikett lagret
+ Etikett slettet
+ Feil ved lasting av etikett. Prøv igjen senere.
+ App låst
+ Bruk PIN-kode istedenfor
+
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
deleted file mode 100644
index c9ceefca3f..0000000000
--- a/app/src/main/res/values-night/themes.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
new file mode 100644
index 0000000000..c92a2be8c0
--- /dev/null
+++ b/app/src/main/res/values-nl/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Afmelden
+ Weet u zeker dat u zich wilt uitloggen?
+ Ja
+ Nee
+ Account Verwijderen
+ U zult worden afgemeld en verwijdert alle informatie verbonden met dit account.
+ Verwijderen
+ Annuleren
+ Kies een ander account
+ U bent offline
+ Functie binnenkort mogelijk
+ Concept opgeslagen
+ Negeren
+ Bericht wordt verzonden…
+ Offline, bericht geplaatst in wachtrij voor verzenden
+ Bericht verzonden
+ Fout bij verzenden bericht
+ Ga naar Concepten
+ Fout bij het uploaden van bijlage
+ Overgeschakeld naar account %s
+ Label opgeslagen
+ Label verwijderd
+ Fout tijdens laden label, probeer het later opnieuw
+ App vergrendeld
+ Gebruik PIN in de plaats
+
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
new file mode 100644
index 0000000000..69818586ae
--- /dev/null
+++ b/app/src/main/res/values-pl/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Wyloguj
+ Czy na pewno chcesz się wylogować?
+ Tak
+ Nie
+ Usuń konto
+ Zostaniesz wylogowany i wszystkie dane powiązane z tym kontem zostaną usunięte.
+ Usuń
+ Anuluj
+ Wybierz inne konto
+ Jesteś w trybie offline
+ Funkcja dostępna wkrótce…
+ Szkic został zapisany
+ Odrzuć
+ Wysyłanie wiadomości…
+ Brak połączenia z siecią. Wiadomość została dodana do kolejki wysyłania
+ Wiadomość została wysłana
+ Wystąpił błąd podczas wysyłania wiadomości
+ Przejdź do Szkiców
+ Wystąpił błąd podczas przesyłania załącznika
+ Przełączono na konto %s
+ Etykieta została zapisana
+ Etykieta została usunięta
+ Wystąpił błąd podczas ładowania etykiety. Spróbuj ponownie później
+ Aplikacja jest zablokowana
+ Użyj kodu PIN
+
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
new file mode 100644
index 0000000000..e41efe8a32
--- /dev/null
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Encerrar sessão
+ Você tem certeza que deseja encerrar a sessão?
+ Sim
+ Não
+ Excluir conta
+ Você encerrará a sessão e removerá toda informação associada a esta conta.
+ Remover
+ Cancelar
+ Selecione outra conta
+ Você está desconectado
+ Este recurso chegará em breve…
+ Rascunho salvo
+ Descartar
+ Enviando mensagem…
+ Sem conexão, mensagem aguardando o envio
+ Mensagem enviada
+ Erro no envio da mensagem
+ Ir para Rascunhos
+ Erro ao enviar anexo
+ Conta alterada para: %s
+ Marcador salvo
+ Marcador excluído
+ Erro ao carregar o marcador, tente novamente mais tarde
+ Aplicativo bloqueado
+ Usar PIN
+
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
new file mode 100644
index 0000000000..39aad47ca1
--- /dev/null
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Terminar sessão
+ Tem a certeza de que deseja terminar a sessão?
+ Sim
+ Não
+ Remover conta
+ Terminará a sessão e removerá toda a informação associada a esta conta.
+ Remover
+ Cancelar
+ Selecionar outra conta
+ Está offline
+ Funcionalidade a disponibilizar em breve…
+ Rascunho guardado
+ Descartar
+ A enviar a mensagem…
+ Sem ligação, a mensagem está na lista para ser enviada
+ Mensagem enviada
+ Erro no envio da mensagem
+ Ir para Rascunhos
+ Erro a enviar o anexo
+ Mudou para a conta %s
+ Etiqueta guardada
+ Etiqueta eliminada
+ Erro ao carregar a etiqueta. Tente outra vez mais tarde.
+ Aplicação bloqueada
+ Utilizar antes PIN
+
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
new file mode 100644
index 0000000000..4ca436d959
--- /dev/null
+++ b/app/src/main/res/values-ro/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Deconectare
+ Sigur doriți să vă deconectați?
+ Da
+ Nu
+ Eliminare cont
+ Vă veți deconecta și veți elimina toate informațiile asociate cu acest cont.
+ Eliminare
+ Anulare
+ Selectare alt cont
+ Sunteți deconectat
+ Funcție disponibilă în curând…
+ Ciornă salvată
+ Aruncare
+ Se trimite mesajul…
+ Deconectat, mesajul va fi trimis ulterior.
+ Mesaj trimis
+ Eroare trimitere mesaj
+ Accesare Ciorne
+ Eroare la încărcare atașament.
+ S-a comutat la contul: %s
+ Etichetă a fost salvată.
+ Etichetă a fost ștearsă.
+ Eroare la încărcarea etichetei. Reîncercați mai târziu.
+ Aplicație blocată
+ Folosiți PIN
+
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000000..0fc100c7a0
--- /dev/null
+++ b/app/src/main/res/values-ru/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Выйти
+ Вы уверены, что хотите выйти?
+ Да
+ Нет
+ Удалить аккаунт
+ Вы выйдете и удалите всю информацию, связанную с этим аккаунтом.
+ Удалить
+ Отменить
+ Выбрать другой аккаунт
+ Вы офлайн
+ Функция скоро появится…
+ Черновик сохранён
+ Не сохранять
+ Отправка сообщения…
+ Офлайн, сообщение добавлено в очередь на отправку
+ Сообщение отправлено
+ Ошибка при отправке сообщения
+ Перейти в Черновики
+ Ошибка загрузки вложения
+ Переключено на аккаунт: %s
+ Ярлык сохранён
+ Ярлык удалён
+ Ошибка загрузки ярлыка, повторите попытку позже
+ Приложение заблокировано
+ Использовать PIN-код вместо этого
+
diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml
new file mode 100644
index 0000000000..1be6abf790
--- /dev/null
+++ b/app/src/main/res/values-sk/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Odhlásiť sa
+ Naozaj sa chcete odhlásiť?
+ Áno
+ Nie
+ Odstrániť účet
+ Odhlásite sa a odstránite všetky informácie spojené s týmto účtom.
+ Odstrániť
+ Zrušiť
+ Vyberte iný účet
+ Ste v režime offline
+ Funkcia už čoskoro…
+ Koncept bol uložený
+ Zahodiť
+ Odosiela sa správa…
+ Offline, správa vo fronte na odoslanie
+ Správa odoslaná
+ Chyba pri odosielaní správy
+ Prejsť do zložky Koncepty
+ Chyba pri nahrávaní prílohy
+ Prepnuté na účet: %s
+ Štítok uložený
+ Štítok odstránený
+ Chyba pri načítaní štítku, skúste to znova neskôr, prosím
+ Aplikácia uzamknutá
+ Použiť radšej PIN
+
diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml
new file mode 100644
index 0000000000..3f299733e1
--- /dev/null
+++ b/app/src/main/res/values-sl/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Odjava
+ Ali ste prepričani, da se želite izpisati?
+ Da
+ Ne
+ Odstrani račun
+ Odjavili se boste in odstranili vse podatke, povezane s tem računom.
+ Odstrani
+ Prekliči
+ Izberite drug račun
+ Niste povezani
+ Funkcija bo kmalu na voljo ...
+ Osnutek shranjen
+ Zavrzi
+ Pošiljanje sporočila …
+ Ni povezave, sporočilo je uvrščeno v pošiljanje
+ Sporočilo poslano
+ Napaka pri pošiljanju sporočila
+ Pojdi v mapo Osnutki
+ Napaka pri nalaganju priponke
+ Preklopili ste na račun: %s
+ Oznaka shranjena
+ Oznaka izbrisana
+ Napaka pri nalaganju oznake, poskusite znova pozneje
+ Aplikacija zaklenjena
+ Namesto tega uporabi kodo PIN
+
diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml
new file mode 100644
index 0000000000..b6af19e2ee
--- /dev/null
+++ b/app/src/main/res/values-sv-rSE/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Logga ut
+ Är du säker på att du vill logga ut?
+ Ja
+ Nej
+ Ta bort konto
+ Du kommer att logga ut och ta bort all information som är associerat till detta konto.
+ Ta bort
+ Avbryt
+ Välj annat konto
+ Du är offline
+ Funktion kommer snart…
+ Utkast sparat
+ Kasta
+ Skickar meddelande…
+ Offline, meddelandet i kö för att skickas
+ Meddelande skickat
+ Det gick inte att skicka meddelandet
+ Gå till utkast
+ Fel vid uppladdning av bilaga
+ Bytte till konto: %s
+ Etikett sparad
+ Etikett borttagen
+ Gick inte läsa in etikett, försök igen senare
+ Appen är låst
+ Använd PIN-kod istället
+
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
new file mode 100644
index 0000000000..7a4c74f575
--- /dev/null
+++ b/app/src/main/res/values-tr/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Oturumu kapat
+ Oturumu kapatmak istediğinize emin misiniz?
+ Evet
+ Hayır
+ Hesabı kaldır
+ Oturumunuz kapatılacak ve bu hesapla ilişkili tüm bilgileriniz silinecek.
+ Kaldır
+ İptal
+ Başka bir hesap seçin
+ Çevrim dışısınız
+ Özellik yakında kullanılabilecek…
+ Taslak kaydedildi
+ Yok say
+ İleti gönderiliyor…
+ Çevrim dışı. İleti gönderilmek üzere kuyruğa alındı
+ İleti gönderildi
+ İleti gönderilirken sorun çıktı
+ Taslaklar kutusuna git
+ Ek dosya yüklenirken sorun çıktı
+ Geçilen hesap: %s
+ Etiket kaydedldi
+ Etiket silindi
+ Etiket yüklenirken sorun çıktı. Lütfen bir süre sonra yeniden deneyin
+ Uygulama kilitlendi
+ Bunun yerine PIN kodu kullanın
+
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
new file mode 100644
index 0000000000..eae305a85d
--- /dev/null
+++ b/app/src/main/res/values-uk/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ Вийти
+ Ви дійсно хочете вийти?
+ Так
+ Ні
+ Вилучити обліковий запис
+ Ви вийдете, а всі пов\'язані з цим обліковим записом дані буде вилучено.
+ Вилучити
+ Скасувати
+ Вибрати інший обліковий запис
+ Ви не в мережі
+ Функція невдовзі з\'явиться…
+ Чернетку збережено
+ Відхилити
+ Надсилання повідомлення…
+ Повідомлення поставлено в чергу через відсутність мережевого з\'єднання
+ Повідомлення надіслано
+ Помилка надсилання повідомлення
+ Перейти до Чернеток
+ Помилка вивантаження вкладення
+ Змінено на обліковий запис: %s
+ Мітку збережено
+ Мітку видалено
+ Помилка завантаження мітки, спробуйте знову пізніше
+ Програму заблоковано
+ Використати PIN-код натомість
+
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000000..401f020fad
--- /dev/null
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ 登出
+ 确定登出?
+ 是
+ 否
+ 移除账号
+ 您即将退出该账户,并删除此设备上与该账户相关的所有数据。
+ 移除
+ 取消
+ 选择其他账户
+ 您处于离线状态
+ 功能即将推出…
+ 草稿已保存
+ 放弃
+ 正在发送邮件…
+ 网络尚未连接,邮件将稍后发送
+ 邮件已发送
+ 发送信息时出错
+ 转至草稿箱
+ 上传附件时出错
+ 已切换至账户:%s
+ 标签已保存
+ 标签已删除
+ 加载标签出错,请稍后再试。
+ 程序已锁定
+ 输入 PIN 码
+
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 0000000000..14d0046751
--- /dev/null
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,45 @@
+
+
+
+ 登出
+ 您確定要登出嗎?
+ 是
+ 否
+ 移除帳號
+ 您將登出,與此帳號相關的所有資訊都會被移除。
+ 移除
+ 取消
+ 選取其它帳號
+ 您處於離線狀態
+ 全新功能即將大展身手...
+ 草稿已儲存
+ 放棄
+ 傳送郵件訊息...
+ 未連線,郵件佇列等候傳送
+ 郵件已傳送
+ 傳送郵件時發生錯誤
+ 前往草稿資料夾
+ 上載附件出現錯誤
+ 已切換至帳號: %s
+ 已儲存標籤
+ 已刪除標籤
+ 載入標籤時發生錯誤,請稍後再試。
+ 應用程式已鎖定
+ 改為使用 PIN 碼
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
deleted file mode 100644
index f8c6127d32..0000000000
--- a/app/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- #FFBB86FC
- #FF6200EE
- #FF3700B3
- #FF03DAC5
- #FF018786
- #FF000000
- #FFFFFFFF
-
\ No newline at end of file
diff --git a/app/src/main/res/values/config.xml b/app/src/main/res/values/config.xml
new file mode 100644
index 0000000000..272a73990c
--- /dev/null
+++ b/app/src/main/res/values/config.xml
@@ -0,0 +1,13 @@
+
+
+ protonmail
+ true
+ false
+ true
+ false
+ true
+
+ false
+ 2
+ true
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8cc13ad03c..304c01ae0b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,46 @@
+
+
- ProtonMail
-
\ No newline at end of file
+ Proton Mail
+ Sign out
+ Are you sure you want to sign out?
+ Yes
+ No
+ Remove Account
+ You will sign out and remove all information associated with this account.
+ Remove
+ Cancel
+ Select another account
+ You are offline
+ Feature coming soon…
+ Draft saved
+ Discard
+ Sending message…
+ Offline, message queued for sending
+ Message sent
+ Error sending message
+ Go to Drafts
+ Error uploading attachment
+ Switched to account %s
+ Label saved
+ Label deleted
+ Error loading label, please try again later
+ App locked
+ Use PIN instead
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
deleted file mode 100644
index 0f1e883838..0000000000
--- a/app/src/main/res/values/themes.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/xml/cache_logs_file_paths.xml b/app/src/main/res/xml/cache_logs_file_paths.xml
new file mode 100644
index 0000000000..bcacceb763
--- /dev/null
+++ b/app/src/main/res/xml/cache_logs_file_paths.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/app/src/test/java/ch/protonmail/android/ExampleUnitTest.kt b/app/src/test/java/ch/protonmail/android/ExampleUnitTest.kt
deleted file mode 100644
index 0363a86ce7..0000000000
--- a/app/src/test/java/ch/protonmail/android/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package ch.protonmail.android
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/app/src/test/kotlin/ch/protonmail/android/di/FeatureFlagModuleTest.kt b/app/src/test/kotlin/ch/protonmail/android/di/FeatureFlagModuleTest.kt
new file mode 100644
index 0000000000..305f8025a4
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/di/FeatureFlagModuleTest.kt
@@ -0,0 +1,78 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.di
+
+import ch.protonmail.android.mailcommon.domain.MailFeatureDefaults
+import ch.protonmail.android.mailcommon.domain.MailFeatureId
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import kotlin.test.assertEquals
+
+@RunWith(Parameterized::class)
+class FeatureFlagModuleTest(private val testInput: TestInput) {
+
+ @Test
+ fun `should provide the correct defaults`() = with(testInput) {
+ // When
+ val actualDefaults = FeatureFlagModule.provideDefaultMailFeatureFlags()
+
+ // Then
+ assertEquals(expectedDefaults, actualDefaults)
+ }
+
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{0}")
+ fun data() = arrayOf(
+ TestInput(
+ buildFlavor = "dev",
+ buildDebug = true,
+ expectedDefaultsMap = mapOf(
+ MailFeatureId.ConversationMode to true,
+ MailFeatureId.RatingBooster to false
+ )
+ ),
+ TestInput(
+ buildFlavor = "alpha",
+ buildDebug = true,
+ expectedDefaultsMap = mapOf(
+ MailFeatureId.ConversationMode to true,
+ MailFeatureId.RatingBooster to false
+ )
+ ),
+ TestInput(
+ buildFlavor = "prod",
+ buildDebug = false,
+ expectedDefaultsMap = mapOf(
+ MailFeatureId.ConversationMode to true,
+ MailFeatureId.RatingBooster to false
+ )
+ )
+ )
+ }
+
+ data class TestInput(
+ val buildFlavor: String,
+ val buildDebug: Boolean,
+ val expectedDefaultsMap: Map
+ ) {
+ val expectedDefaults = MailFeatureDefaults(expectedDefaultsMap)
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/feature/account/SignOutAccountViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/feature/account/SignOutAccountViewModelTest.kt
new file mode 100644
index 0000000000..e9ed9fff77
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/feature/account/SignOutAccountViewModelTest.kt
@@ -0,0 +1,116 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.feature.account
+
+import app.cash.turbine.test
+import ch.protonmail.android.mailcommon.data.worker.Enqueuer
+import ch.protonmail.android.test.utils.rule.MainDispatcherRule
+import ch.protonmail.android.testdata.user.UserIdTestData
+import io.mockk.Runs
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.test.runTest
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.test.kotlin.TestDispatcherProvider
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import kotlin.test.assertEquals
+
+class SignOutAccountViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule(TestDispatcherProvider().Main)
+
+ private val accountManager = mockk(relaxUnitFun = true)
+ private val enqueuer = mockk()
+ private val viewModel = SignOutAccountViewModel(accountManager, enqueuer)
+
+ @Before
+ fun setup() {
+ every { accountManager.getPrimaryUserId() } returns flowOf(BaseUserId)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `when initialized emits initial state`() = runTest {
+ // When
+ val actual = viewModel.state.take(1).first()
+
+ // Then
+ assertEquals(SignOutAccountViewModel.State.Initial, actual)
+ }
+
+ @Test
+ fun `when sign out is called emits signing out and then signed out when completed`() = runTest {
+ // Given
+ every { enqueuer.cancelAllWork(BaseUserId) } just Runs
+
+ // When
+ viewModel.signOut()
+
+ // Then
+ viewModel.state.test {
+ assertEquals(SignOutAccountViewModel.State.Initial, awaitItem())
+ assertEquals(SignOutAccountViewModel.State.SigningOut, awaitItem())
+ assertEquals(SignOutAccountViewModel.State.SignedOut, awaitItem())
+
+ coVerify { accountManager.disableAccount(BaseUserId) }
+ coVerify(exactly = 0) { accountManager.removeAccount(any()) }
+ }
+ }
+
+ @Test
+ fun `when sign out is called cancel all work related to this user`() = runTest {
+ // Given
+ every { enqueuer.cancelAllWork(BaseUserId) } just Runs
+
+ // When
+ viewModel.signOut(BaseUserId)
+
+ // Then
+ viewModel.state.test {
+ assertEquals(SignOutAccountViewModel.State.Initial, awaitItem())
+ assertEquals(SignOutAccountViewModel.State.SigningOut, awaitItem())
+ assertEquals(SignOutAccountViewModel.State.SignedOut, awaitItem())
+
+ coVerify {
+ enqueuer.cancelAllWork(BaseUserId)
+ accountManager.disableAccount(BaseUserId)
+ }
+ coVerify(exactly = 0) { accountManager.removeAccount(any()) }
+ }
+ }
+
+ private companion object {
+
+ val BaseUserId = UserIdTestData.Primary
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/feature/alternativerouting/HasAlternativeRoutingTest.kt b/app/src/test/kotlin/ch/protonmail/android/feature/alternativerouting/HasAlternativeRoutingTest.kt
new file mode 100644
index 0000000000..bf9675a08c
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/feature/alternativerouting/HasAlternativeRoutingTest.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.feature.alternativerouting
+
+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.mailsettings.domain.model.AlternativeRoutingPreference
+import ch.protonmail.android.mailsettings.domain.repository.AlternativeRoutingRepository
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+class HasAlternativeRoutingTest {
+
+ private val alternativeRoutingRepository = mockk()
+
+ private lateinit var hasAlternativeRouting: HasAlternativeRouting
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+
+ hasAlternativeRouting = HasAlternativeRouting(
+ alternativeRoutingRepository,
+ TestScope()
+ )
+ }
+
+ @Test
+ fun `emits true alternative routing preference as initial state`() = runTest {
+ // Given
+ every { alternativeRoutingRepository.observe() } returns flowOf()
+ // When
+ hasAlternativeRouting.invoke().test {
+
+ // Then
+ assertEquals(AlternativeRoutingPreference(true), awaitItem())
+ }
+ }
+
+ @Test
+ fun `emits alternative routing preference from repository when repository emits`() = runTest {
+ // Given
+ every { alternativeRoutingRepository.observe() } returns flowOf(
+ AlternativeRoutingPreference(false).right()
+ )
+ // When
+ hasAlternativeRouting.invoke().test {
+ awaitItem() // Intial state
+ // Then
+ assertEquals(AlternativeRoutingPreference(false), awaitItem())
+ }
+ }
+
+ @Test
+ fun `emits alternative routing preference initial value when an error happens`() = runTest {
+ // Given
+ every { alternativeRoutingRepository.observe() } returns flowOf(
+ PreferencesError.left()
+ )
+ // When
+ hasAlternativeRouting.invoke().test {
+ // Then
+ assertEquals(AlternativeRoutingPreference(true), awaitItem())
+ }
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscriptionTest.kt b/app/src/test/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscriptionTest.kt
new file mode 100644
index 0000000000..4d72f5acaf
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscriptionTest.kt
@@ -0,0 +1,204 @@
+package ch.protonmail.android.feature.postsubscription
+
+import androidx.appcompat.app.AppCompatActivity
+import ch.protonmail.android.mailcommon.domain.sample.UserSample
+import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUser
+import ch.protonmail.android.mailupselling.domain.model.UserUpgradeState
+import ch.protonmail.android.testdata.user.UserIdTestData
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+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 me.proton.core.featureflag.domain.entity.FeatureFlag
+import me.proton.core.featureflag.domain.entity.FeatureId
+import me.proton.core.featureflag.domain.entity.Scope
+import me.proton.core.user.domain.entity.User
+import org.junit.After
+import org.junit.Before
+import kotlin.test.Test
+
+class ObservePostSubscriptionTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val observePostSubscriptionFlowEnabled = mockk {
+ every { this@mockk.invoke(FreeUser.userId) } returns flowOf(
+ FeatureFlag(
+ userId = UserIdTestData.userId,
+ featureId = FeatureId(""),
+ scope = Scope.Unleash,
+ defaultValue = false,
+ value = true
+ )
+ )
+ }
+ private val observePrimaryUser = mockk()
+ private val userUpgradeState = mockk()
+
+ private val observePostSubscription = ObservePostSubscription(
+ observePostSubscriptionFlowEnabled = observePostSubscriptionFlowEnabled,
+ observePrimaryUser = observePrimaryUser,
+ userUpgradeState = userUpgradeState
+ )
+
+ private val mockActivity = mockk(relaxUnitFun = true)
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @After
+ fun teardown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `upon a paid user, cancel further calls`() = runTest {
+ // Given
+ val activity = mockActivity
+ expectPaidUser()
+
+ // When
+ observePostSubscription.start(activity)
+
+ // Then
+ coVerify(exactly = 0) { observePostSubscriptionFlowEnabled(any()) }
+
+ }
+
+ @Test
+ fun `upon a free user without valid purchases, do not show post-sub activity`() = runTest {
+ // Given
+ val activity = mockActivity
+ expectFreeUser()
+ expectUpgradeCheckStates(flowOf(UserUpgradeState.UserUpgradeCheckState.Completed))
+
+ // When
+ observePostSubscription.start(activity)
+
+ // Then
+ coVerify(exactly = 1) { observePostSubscriptionFlowEnabled(any()) }
+ verify(exactly = 0) { activity.startActivity(any()) }
+ }
+
+ @Test
+ fun `upon a free user with valid purchases for Mail Plus, show post-sub activity`() = runTest {
+ // Given
+ val activity = mockActivity
+ expectFreeUser()
+ expectUpgradeCheckStates(
+ flowOf(
+ UserUpgradeState.UserUpgradeCheckState.Pending,
+ UserUpgradeState.UserUpgradeCheckState.CompletedWithUpgrade(listOf(MailPlusPlanName))
+ )
+ )
+
+ // When
+ observePostSubscription.start(activity)
+
+ // Then
+ coVerify(exactly = 1) { observePostSubscriptionFlowEnabled(any()) }
+ verify(exactly = 1) { activity.startActivity(any()) }
+ }
+
+ @Test
+ fun `upon a free user with valid purchases for a different plan, don't show post-sub activity`() = runTest {
+ // Given
+ val activity = mockActivity
+ expectFreeUser()
+ expectUpgradeCheckStates(
+ flowOf(
+ UserUpgradeState.UserUpgradeCheckState.Pending,
+ UserUpgradeState.UserUpgradeCheckState.CompletedWithUpgrade(listOf(OtherPlanName))
+ )
+ )
+
+ // When
+ observePostSubscription.start(activity)
+
+ // Then
+ coVerify(exactly = 1) { observePostSubscriptionFlowEnabled(any()) }
+ verify(exactly = 0) { activity.startActivity(any()) }
+ }
+
+ @Test
+ fun `upon a free user with pending purchases but no ack, don't show post-sub activity`() = runTest {
+ // Given
+ val activity = mockActivity
+ expectFreeUser()
+ expectUpgradeCheckStates(
+ flowOf(
+ UserUpgradeState.UserUpgradeCheckState.Pending,
+ UserUpgradeState.UserUpgradeCheckState.Completed
+ )
+ )
+
+ // When
+ observePostSubscription.start(activity)
+
+ // Then
+ coVerify(exactly = 1) { observePostSubscriptionFlowEnabled(any()) }
+ verify(exactly = 0) { activity.startActivity(any()) }
+ }
+
+ @Test
+ fun `upon a free user with pending purchases, show post-sub activity upon a new paid user emission`() = runTest {
+ // Given
+ val activity = mockActivity
+ expectUsersFlow(
+ flow {
+ emit(FreeUser)
+ delay(2000)
+ emit(PaidUser)
+ }
+ )
+ expectUpgradeCheckStates(
+ flow {
+ emit(UserUpgradeState.UserUpgradeCheckState.Pending)
+ emit(UserUpgradeState.UserUpgradeCheckState.CompletedWithUpgrade(listOf(MailPlusPlanName)))
+ }
+ )
+
+ // When
+ observePostSubscription.start(activity)
+
+ // Then
+ coVerify(exactly = 1) { observePostSubscriptionFlowEnabled(any()) }
+ verify(exactly = 1) { activity.startActivity(any()) }
+ }
+
+ private fun expectFreeUser() {
+ every { observePrimaryUser() } returns flowOf(FreeUser)
+ }
+
+ private fun expectPaidUser() {
+ every { observePrimaryUser() } returns flowOf(PaidUser)
+ }
+
+ private fun expectUsersFlow(flow: Flow) {
+ every { observePrimaryUser() } returns flow
+ }
+
+ private fun expectUpgradeCheckStates(flow: Flow) {
+ coEvery { userUpgradeState.userUpgradeCheckState } coAnswers { flow }
+ }
+
+ companion object {
+
+ private const val OtherPlanName = "plan-123"
+ private const val MailPlusPlanName = "mail2022"
+
+ private val PaidUser = UserSample.Primary.copy(subscribed = 1)
+ private val FreeUser = UserSample.Primary.copy(subscribed = 0)
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/initializer/featureflag/RefreshRatingBoosterFeatureFlagsTest.kt b/app/src/test/kotlin/ch/protonmail/android/initializer/featureflag/RefreshRatingBoosterFeatureFlagsTest.kt
new file mode 100644
index 0000000000..9cef67783b
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/initializer/featureflag/RefreshRatingBoosterFeatureFlagsTest.kt
@@ -0,0 +1,66 @@
+package ch.protonmail.android.initializer.featureflag
+
+import ch.protonmail.android.mailcommon.domain.MailFeatureId
+import ch.protonmail.android.mailcommon.domain.sample.AccountSample
+import ch.protonmail.android.testdata.AccountTestData
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.domain.entity.UserId
+import me.proton.core.featureflag.domain.FeatureFlagManager
+import me.proton.core.featureflag.domain.entity.FeatureFlag
+import me.proton.core.test.kotlin.TestCoroutineScopeProvider
+import me.proton.core.test.kotlin.TestDispatcherProvider
+import kotlin.test.Test
+
+class RefreshRatingBoosterFeatureFlagsTest {
+
+ private val dispatcherProvider = TestDispatcherProvider(UnconfinedTestDispatcher())
+ private val scopeProvider = TestCoroutineScopeProvider(dispatcherProvider)
+
+ private val accountManager = mockk()
+ private val featureFlagManager = mockk()
+
+ private val refreshRatingBoosterFeatureFlags = RefreshRatingBoosterFeatureFlags(
+ accountManager,
+ scopeProvider,
+ featureFlagManager
+ )
+
+ @Test
+ fun `should refresh rating booster feature flags for all users when the use case is called`() {
+ // Given
+ coEvery {
+ accountManager.getAccounts()
+ } returns flowOf(listOf(AccountTestData.readyAccount, AccountSample.Primary))
+ coEvery {
+ featureFlagManager.getOrDefault(
+ userId = any(),
+ featureId = MailFeatureId.RatingBooster.id,
+ default = FeatureFlag.default(MailFeatureId.RatingBooster.id.id, false),
+ refresh = true
+ )
+ } returns FeatureFlag.default(MailFeatureId.RatingBooster.id.id, false)
+
+ // When
+ refreshRatingBoosterFeatureFlags()
+
+ // Then
+ verifyRatingBoosterFeatureFlagRefreshedForUser(AccountTestData.readyAccount.userId)
+ verifyRatingBoosterFeatureFlagRefreshedForUser(AccountSample.Primary.userId)
+ }
+
+ private fun verifyRatingBoosterFeatureFlagRefreshedForUser(userId: UserId) {
+ coVerify {
+ featureFlagManager.getOrDefault(
+ userId = userId,
+ featureId = MailFeatureId.RatingBooster.id,
+ default = FeatureFlag.default(MailFeatureId.RatingBooster.id.id, false),
+ refresh = true
+ )
+ }
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/logging/SentryUserObserverTest.kt b/app/src/test/kotlin/ch/protonmail/android/logging/SentryUserObserverTest.kt
new file mode 100644
index 0000000000..f0459aa964
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/logging/SentryUserObserverTest.kt
@@ -0,0 +1,91 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.logging
+
+import java.util.UUID
+import ch.protonmail.android.testdata.user.UserIdTestData
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import io.sentry.Sentry
+import io.sentry.protocol.User
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.test.kotlin.TestCoroutineScopeProvider
+import me.proton.core.test.kotlin.TestDispatcherProvider
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class SentryUserObserverTest {
+
+ private val accountManager = mockk {
+ every { this@mockk.getPrimaryUserId() } returns flowOf(UserIdTestData.userId)
+ }
+
+ private lateinit var sentryUserObserver: SentryUserObserver
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(TestDispatcherProvider().Main)
+ mockkStatic(Sentry::class)
+ sentryUserObserver = SentryUserObserver(
+ scopeProvider = TestCoroutineScopeProvider(),
+ accountManager = accountManager
+ )
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ unmockkStatic(Sentry::class)
+ }
+
+ @Test
+ fun `register userId in Sentry for valid primary account`() = runTest {
+ // When
+ sentryUserObserver.start().join()
+ // Then
+ val sentryUserSlot = slot()
+ verify { Sentry.setUser(capture(sentryUserSlot)) }
+ assertEquals(UserIdTestData.userId.id, sentryUserSlot.captured.id)
+ }
+
+ @Test
+ fun `register random UUID in Sentry when no primary account available`() = runTest {
+ // Given
+ every { accountManager.getPrimaryUserId() } returns flowOf(null)
+ // When
+ sentryUserObserver.start().join()
+ // Then
+ val sentryUserSlot = slot()
+ verify { Sentry.setUser(capture(sentryUserSlot)) }
+ val actual = UUID.fromString(sentryUserSlot.captured.id)
+ assertTrue(actual.toString().isNotBlank())
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/mailmessage/presentation/mapper/AttachmentUiModelMapperTest.kt b/app/src/test/kotlin/ch/protonmail/android/mailmessage/presentation/mapper/AttachmentUiModelMapperTest.kt
new file mode 100644
index 0000000000..1bcdd0aee2
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/mailmessage/presentation/mapper/AttachmentUiModelMapperTest.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.mailmessage.presentation.mapper
+
+import ch.protonmail.android.mailmessage.domain.sample.MessageAttachmentSample
+import ch.protonmail.android.mailmessage.presentation.sample.AttachmentUiModelSample
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@Deprecated("Part of Composer V1, to be replaced with AttachmentUiModelMapper2Test")
+class AttachmentUiModelMapperTest {
+
+ private val attachmentUiModelMapper = AttachmentUiModelMapper()
+
+ @Test
+ fun `should map pdf message attachment with application pdf mime type to a ui model`() {
+ // When
+ val actual = attachmentUiModelMapper.toUiModel(MessageAttachmentSample.invoice)
+
+ // Then
+ val expected = AttachmentUiModelSample.invoice
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should map message attachment with application doc mime type to a ui model`() {
+ // When
+ val actual = attachmentUiModelMapper.toUiModel(MessageAttachmentSample.document)
+
+ // Then
+ val expected = AttachmentUiModelSample.document
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun `should map message attachment with multiple dots in the name to a ui model`() {
+ // When
+ val actual = attachmentUiModelMapper.toUiModel(MessageAttachmentSample.documentWithMultipleDots)
+
+ // Then
+ val expected = AttachmentUiModelSample.documentWithMultipleDots
+ assertEquals(expected, actual)
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/HomeViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/HomeViewModelTest.kt
new file mode 100644
index 0000000000..fb85cd020d
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/navigation/HomeViewModelTest.kt
@@ -0,0 +1,547 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation
+
+import android.content.Intent
+import android.net.Uri
+import app.cash.turbine.test
+import ch.protonmail.android.mailcommon.data.file.getShareInfo
+import ch.protonmail.android.mailcommon.domain.model.IntentShareInfo
+import ch.protonmail.android.mailcommon.domain.sample.UserSample
+import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUser
+import ch.protonmail.android.mailcommon.presentation.Effect
+import ch.protonmail.android.mailcomposer.domain.model.MessageSendingStatus
+import ch.protonmail.android.mailcomposer.domain.usecase.DiscardDraft
+import ch.protonmail.android.mailcomposer.domain.usecase.ObserveSendingMessagesStatus
+import ch.protonmail.android.mailcomposer.domain.usecase.ResetSendingMessagesStatus
+import ch.protonmail.android.maillabel.domain.SelectedMailLabelId
+import ch.protonmail.android.mailmailbox.domain.usecase.RecordMailboxScreenView
+import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample
+import ch.protonmail.android.mailnotifications.domain.model.telemetry.NotificationPermissionTelemetryEventType
+import ch.protonmail.android.mailnotifications.domain.usecase.SavePermissionDialogTimestamp
+import ch.protonmail.android.mailnotifications.domain.usecase.SaveShouldStopShowingPermissionDialog
+import ch.protonmail.android.mailnotifications.domain.usecase.ShouldShowNotificationPermissionDialog
+import ch.protonmail.android.mailnotifications.domain.usecase.TrackNotificationPermissionTelemetryEvent
+import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogState
+import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogType
+import ch.protonmail.android.mailsettings.domain.usecase.autolock.ShouldPresentPinInsertionScreen
+import ch.protonmail.android.navigation.model.HomeState
+import ch.protonmail.android.navigation.share.ShareIntentObserver
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import me.proton.core.network.domain.NetworkManager
+import me.proton.core.network.domain.NetworkStatus
+import me.proton.core.user.domain.entity.User
+import org.junit.Assert.assertNull
+import javax.inject.Provider
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+class HomeViewModelTest {
+
+ private val user = UserSample.Primary
+
+ private val networkManager = mockk()
+
+ private val observePrimaryUserMock = mockk {
+ every { this@mockk() } returns MutableStateFlow(user)
+ }
+
+ private val observeSendingMessagesStatus = mockk {
+ every { this@mockk.invoke(any()) } returns flowOf(MessageSendingStatus.None)
+ }
+
+ private val recordMailboxScreenView = mockk(relaxUnitFun = true)
+
+ private val resetSendingMessageStatus = mockk(relaxUnitFun = true)
+
+ private val selectedMailLabelId = mockk(relaxUnitFun = true)
+
+ private val shouldPresentPinInsertionScreen = mockk {
+ every { this@mockk.invoke() } returns flowOf(false)
+ }
+
+ private val shareIntentObserver = mockk(relaxUnitFun = true) {
+ every { this@mockk() } returns emptyFlow()
+ }
+
+ private val discardDraft = mockk(relaxUnitFun = true)
+
+ private val shouldShowNotificationPermissionDialog = mockk {
+ coEvery { this@mockk(currentTimeMillis = any(), isMessageSent = false) } returns false
+ }
+ private val savePermissionDialogTimestamp = mockk(relaxUnitFun = true)
+ private val saveShouldStopShowingPermissionDialog = mockk(
+ relaxUnitFun = true
+ )
+ private val trackNotificationPermissionTelemetry = mockk(
+ relaxUnitFun = true
+ )
+
+ private val isComposerV2Enabled = mockk> {
+ every { this@mockk.get() } returns false
+ }
+
+ private val homeViewModel by lazy {
+ HomeViewModel(
+ networkManager,
+ observeSendingMessagesStatus,
+ recordMailboxScreenView,
+ resetSendingMessageStatus,
+ selectedMailLabelId,
+ discardDraft,
+ shouldShowNotificationPermissionDialog,
+ savePermissionDialogTimestamp,
+ saveShouldStopShowingPermissionDialog,
+ trackNotificationPermissionTelemetry,
+ isComposerV2Enabled.get(),
+ observePrimaryUserMock,
+ shareIntentObserver
+ )
+ }
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ mockkStatic(Uri::class)
+ }
+
+ @AfterTest
+ fun teardown() {
+ unmockkAll()
+ unmockkStatic(Uri::class)
+ }
+
+ @Test
+ fun `when initialized then emit initial state`() = runTest {
+ // Given
+ every { networkManager.observe() } returns emptyFlow()
+
+ // When
+ homeViewModel.state.test {
+ val actualItem = awaitItem()
+ val expectedItem = HomeState.Initial
+
+ // Then
+ assertEquals(expectedItem, actualItem)
+ }
+ }
+
+ @Test
+ fun `should emit a new state with started from launcher set when intent with main action is received`() = runTest {
+ // Given
+ val mainIntent = mockIntent(
+ action = Intent.ACTION_MAIN,
+ data = null
+ )
+ every { networkManager.observe() } returns emptyFlow()
+ every { shareIntentObserver() } returns flowOf(mainIntent)
+
+ // When
+ homeViewModel.state.test {
+ val actualItem = awaitItem()
+ val expectedItem = HomeState.Initial.copy(
+ startedFromLauncher = true
+ )
+
+ // Then
+ assertEquals(expectedItem, actualItem)
+ }
+ }
+
+ @Test
+ fun `when the status is disconnected and is still disconnected after 5 seconds then emit disconnected status`() =
+ runTest {
+ // Given
+ every { networkManager.observe() } returns flowOf(NetworkStatus.Disconnected)
+ every { networkManager.networkStatus } returns NetworkStatus.Disconnected
+
+ // When
+ homeViewModel.state.test {
+ awaitItem()
+ advanceUntilIdle()
+ val actualItem = awaitItem()
+ val expectedItem = HomeState(
+ notificationPermissionDialogState = NotificationPermissionDialogState.Hidden,
+ networkStatusEffect = Effect.of(NetworkStatus.Disconnected),
+ messageSendingStatusEffect = Effect.empty(),
+ navigateToEffect = Effect.empty(),
+ startedFromLauncher = false
+ )
+
+ // Then
+ assertEquals(expectedItem, actualItem)
+ }
+ }
+
+ @Test
+ fun `when the status is disconnected and is metered after 5 seconds then emit metered status`() = runTest {
+ // Given
+ every { networkManager.observe() } returns flowOf(NetworkStatus.Disconnected)
+ every { networkManager.networkStatus } returns NetworkStatus.Metered
+
+ // When
+ homeViewModel.state.test {
+ awaitItem()
+ advanceUntilIdle()
+ val actualItem = awaitItem()
+ val expectedItem = HomeState(
+ notificationPermissionDialogState = NotificationPermissionDialogState.Hidden,
+ networkStatusEffect = Effect.of(NetworkStatus.Metered),
+ messageSendingStatusEffect = Effect.empty(),
+ navigateToEffect = Effect.empty(),
+ startedFromLauncher = false
+ )
+
+ // Then
+ assertEquals(expectedItem, actualItem)
+ }
+ }
+
+ @Test
+ fun `when the status is metered then emit metered status`() = runTest {
+ // Given
+ every { networkManager.observe() } returns flowOf(NetworkStatus.Metered)
+
+ // When
+ homeViewModel.state.test {
+ val actualItem = awaitItem()
+ val expectedItem = HomeState(
+ notificationPermissionDialogState = NotificationPermissionDialogState.Hidden,
+ networkStatusEffect = Effect.of(NetworkStatus.Metered),
+ messageSendingStatusEffect = Effect.empty(),
+ navigateToEffect = Effect.empty(),
+ startedFromLauncher = false
+ )
+
+ // Then
+ assertEquals(expectedItem, actualItem)
+ }
+ }
+
+ @Test
+ fun `when observe sending message status emits Send and then None then emit only send effect`() = runTest {
+ // Given
+ every { networkManager.observe() } returns flowOf(NetworkStatus.Metered)
+ val sendingMessageStatusFlow = MutableStateFlow(MessageSendingStatus.MessageSent)
+ every { observeSendingMessagesStatus(user.userId) } returns sendingMessageStatusFlow
+ coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = true) } returns false
+
+ // When
+ homeViewModel.state.test {
+ val actualItem = awaitItem()
+ val expectedItem = HomeState(
+ notificationPermissionDialogState = NotificationPermissionDialogState.Hidden,
+ networkStatusEffect = Effect.of(NetworkStatus.Metered),
+ messageSendingStatusEffect = Effect.of(MessageSendingStatus.MessageSent),
+ navigateToEffect = Effect.empty(),
+ startedFromLauncher = false
+ )
+ sendingMessageStatusFlow.emit(MessageSendingStatus.None)
+
+ // Then
+ assertEquals(expectedItem, actualItem)
+ }
+ }
+
+ @Test
+ fun `when observe sending message status emits error then emit effect and reset sending messages status`() =
+ runTest {
+ // Given
+ every { networkManager.observe() } returns flowOf(NetworkStatus.Metered)
+ every { observeSendingMessagesStatus(user.userId) } returns flowOf(
+ MessageSendingStatus.SendMessageError
+ )
+
+ // When
+ homeViewModel.state.test {
+ val actualItem = awaitItem()
+ val expectedItem = HomeState(
+ notificationPermissionDialogState = NotificationPermissionDialogState.Hidden,
+ networkStatusEffect = Effect.of(NetworkStatus.Metered),
+ messageSendingStatusEffect = Effect.of(MessageSendingStatus.SendMessageError),
+ navigateToEffect = Effect.empty(),
+ startedFromLauncher = false
+ )
+
+ // Then
+ assertEquals(expectedItem, actualItem)
+ coVerify { resetSendingMessageStatus(user.userId) }
+ }
+ }
+
+ @Test
+ fun `when pin lock screen needs to be shown, the effect is emitted accordingly`() = runTest {
+ // Given
+ every { networkManager.observe() } returns flowOf(NetworkStatus.Unmetered)
+ every { shouldPresentPinInsertionScreen() } returns flowOf(true)
+
+ // When + Then
+ homeViewModel.state.test {
+ val actualItem = awaitItem()
+ val expectedItem = HomeState.Initial.copy(
+ networkStatusEffect = Effect.of(NetworkStatus.Unmetered)
+ )
+ assertEquals(expectedItem, actualItem)
+ }
+ }
+
+ @Test
+ fun `should emit a new state with navigation effect when a share intent is received`() = runTest {
+ // Given
+ val fileUriStr = "content://media/1234"
+ val fileUri = mockk()
+ val intentShareInfo = IntentShareInfo.Empty.copy(
+ attachmentUris = listOf(fileUriStr)
+ )
+ val shareIntent = mockIntent(
+ action = Intent.ACTION_SEND,
+ data = fileUri
+ )
+ // Mock the extension function
+ mockkStatic("ch.protonmail.android.mailcommon.data.file.IntentShareExtensionsKt")
+ every { any().getShareInfo() } returns intentShareInfo
+
+ every { networkManager.observe() } returns flowOf()
+ every { shouldPresentPinInsertionScreen() } returns flowOf()
+ every { shareIntentObserver() } returns flowOf(shareIntent)
+
+ // When + Then
+ homeViewModel.state.test {
+ val actualItem = awaitItem()
+ assertNotNull(actualItem.navigateToEffect.consume())
+ }
+ }
+
+ @Test
+ fun `should not emit a new navigation state when file share info is empty`() = runTest {
+ // Given
+ val fileUri = mockk()
+ val shareIntent = mockIntent(
+ action = Intent.ACTION_VIEW,
+ data = fileUri
+ )
+ // Mock the extension function
+ mockkStatic("ch.protonmail.android.mailcommon.data.file.IntentShareExtensionsKt")
+ every { any().getShareInfo() } returns IntentShareInfo.Empty
+
+ every { networkManager.observe() } returns flowOf()
+ every { shouldPresentPinInsertionScreen() } returns flowOf()
+ every { shareIntentObserver() } returns flowOf(shareIntent)
+
+ // When + Then
+ homeViewModel.state.test {
+ val actualItem = awaitItem()
+ assertNull(actualItem.navigateToEffect.consume())
+ }
+ }
+
+ @Test
+ fun `should not emit a new navigation state when activity was started from launcher`() = runTest {
+ // Given
+ val fileUriStr = "content://media/1234"
+ val fileUri = mockk()
+ val intentShareInfo = IntentShareInfo.Empty.copy(
+ attachmentUris = listOf(fileUriStr)
+ )
+ val shareIntent = mockIntent(
+ action = Intent.ACTION_SEND,
+ data = fileUri
+ )
+ val mainIntent = mockIntent(
+ action = Intent.ACTION_MAIN,
+ data = null
+ )
+ // Mock the extension function
+ mockkStatic("ch.protonmail.android.mailcommon.data.file.IntentShareExtensionsKt")
+ every { any().getShareInfo() } returns intentShareInfo
+
+ every { networkManager.observe() } returns flowOf()
+ every { shouldPresentPinInsertionScreen() } returns flowOf()
+ every { shareIntentObserver() } returns flowOf(mainIntent, shareIntent)
+
+ // When + Then
+ homeViewModel.state.test {
+ val actualItem = awaitItem()
+ assertNull(actualItem.navigateToEffect.consume())
+ }
+ }
+
+ @Test
+ fun `should discard draft when discard draft is called`() = runTest {
+ // Given
+ val messageId = MessageIdSample.LocalDraft
+
+ every { networkManager.observe() } returns flowOf()
+
+ // When
+ homeViewModel.discardDraft(messageId)
+
+ // Then
+ coVerify { discardDraft(user.userId, messageId) }
+ }
+
+ @Test
+ fun `should call use case when recording mailbox screen view count`() {
+ // Given
+ every { networkManager.observe() } returns flowOf()
+
+ // When
+ homeViewModel.recordViewOfMailboxScreen()
+
+ // Then
+ verify { recordMailboxScreenView() }
+ }
+
+ @Test
+ fun `should show notification permission dialog when initializing if use case return true`() = runTest {
+ // Given
+ every { networkManager.observe() } returns flowOf()
+ coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = false) } returns true
+
+ // When
+ homeViewModel.state.test {
+ val item = awaitItem()
+
+ // Then
+ val expected = NotificationPermissionDialogState.Shown(
+ type = NotificationPermissionDialogType.PostOnboarding
+ )
+ assertEquals(expected, item.notificationPermissionDialogState)
+ coVerify { savePermissionDialogTimestamp(any()) }
+ verify {
+ trackNotificationPermissionTelemetry(
+ NotificationPermissionTelemetryEventType.NotificationPermissionDialogDisplayed(
+ NotificationPermissionDialogType.PostOnboarding
+ )
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `should not show notification permission dialog when initializing if use case return false`() = runTest {
+ // Given
+ every { networkManager.observe() } returns flowOf()
+ coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = false) } returns false
+
+ // When
+ homeViewModel.state.test {
+ val item = awaitItem()
+
+ // Then
+ val expected = NotificationPermissionDialogState.Hidden
+ assertEquals(expected, item.notificationPermissionDialogState)
+ verify(exactly = 0) { trackNotificationPermissionTelemetry(any()) }
+ }
+ }
+
+ @Test
+ fun `should show notification permission dialog when message was sent if use case returns true`() = runTest {
+ // Given
+ every { networkManager.observe() } returns flowOf()
+ coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = false) } returns false
+ coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = true) } returns true
+ every { observeSendingMessagesStatus(user.userId) } returns flowOf(MessageSendingStatus.MessageSent)
+
+ // When
+ homeViewModel.state.test {
+ val item = awaitItem()
+
+ // Then
+ val expected = NotificationPermissionDialogState.Shown(
+ type = NotificationPermissionDialogType.PostSending
+ )
+ assertEquals(expected, item.notificationPermissionDialogState)
+ coVerify { saveShouldStopShowingPermissionDialog() }
+ verify {
+ trackNotificationPermissionTelemetry(
+ NotificationPermissionTelemetryEventType.NotificationPermissionDialogDisplayed(
+ NotificationPermissionDialogType.PostSending
+ )
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `should hide notification permission dialog when close method is called`() = runTest {
+ // Given
+ every { networkManager.observe() } returns flowOf()
+ coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = false) } returns true
+
+ // When
+ homeViewModel.state.test {
+ skipItems(1)
+ homeViewModel.closeNotificationPermissionDialog()
+ val item = awaitItem()
+
+ // Then
+ val expected = NotificationPermissionDialogState.Hidden
+ assertEquals(expected, item.notificationPermissionDialogState)
+ }
+ }
+
+ @Test
+ fun `should track telemetry event when the method is called`() = runTest {
+ // Given
+ every { networkManager.observe() } returns flowOf()
+ coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = false) } returns true
+
+ // When
+ homeViewModel.trackTelemetryEvent(
+ NotificationPermissionTelemetryEventType.NotificationPermissionDialogDisplayed(
+ NotificationPermissionDialogType.PostOnboarding
+ )
+ )
+
+ // Then
+ verify {
+ trackNotificationPermissionTelemetry(
+ NotificationPermissionTelemetryEventType.NotificationPermissionDialogDisplayed(
+ NotificationPermissionDialogType.PostOnboarding
+ )
+ )
+ }
+ }
+
+ private fun mockIntent(action: String, data: Uri?): Intent {
+ return mockk {
+ every { this@mockk.action } returns action
+ every { this@mockk.data } returns data
+ }
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherRouterViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherRouterViewModelTest.kt
new file mode 100644
index 0000000000..adf76735b1
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherRouterViewModelTest.kt
@@ -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 .
+ */
+
+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()
+
+ 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())
+ }
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherViewModelTest.kt
new file mode 100644
index 0000000000..b59985836b
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherViewModelTest.kt
@@ -0,0 +1,320 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation
+
+import androidx.appcompat.app.AppCompatActivity
+import app.cash.turbine.test
+import ch.protonmail.android.mailnotifications.presentation.NotificationPermissionOrchestrator
+import ch.protonmail.android.navigation.model.LauncherState
+import ch.protonmail.android.testdata.AccountTestData
+import ch.protonmail.android.testdata.user.UserIdTestData.userId
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import me.proton.core.account.domain.entity.Account
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.accountmanager.presentation.AccountManagerObserver
+import me.proton.core.accountmanager.presentation.observe
+import me.proton.core.accountmanager.presentation.onAccountCreateAddressFailed
+import me.proton.core.accountmanager.presentation.onAccountCreateAddressNeeded
+import me.proton.core.accountmanager.presentation.onAccountDeviceSecretNeeded
+import me.proton.core.accountmanager.presentation.onAccountTwoPassModeFailed
+import me.proton.core.accountmanager.presentation.onAccountTwoPassModeNeeded
+import me.proton.core.accountmanager.presentation.onSessionForceLogout
+import me.proton.core.accountmanager.presentation.onSessionSecondFactorNeeded
+import me.proton.core.auth.presentation.AuthOrchestrator
+import me.proton.core.auth.presentation.MissingScopeObserver
+import me.proton.core.humanverification.presentation.HumanVerificationManagerObserver
+import me.proton.core.plan.presentation.PlansOrchestrator
+import me.proton.core.report.presentation.ReportOrchestrator
+import me.proton.core.usersettings.presentation.UserSettingsOrchestrator
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class LauncherViewModelTest {
+
+ private val authOrchestrator = mockk(relaxUnitFun = true)
+ private val plansOrchestrator = mockk(relaxUnitFun = true)
+ private val reportOrchestrator = mockk(relaxUnitFun = true)
+ private val userSettingsOrchestrator = mockk(relaxUnitFun = true)
+ private val notificationPermissionOrchestrator = mockk(
+ relaxUnitFun = true
+ )
+
+ private val accountListFlow = MutableStateFlow>(emptyList())
+ private val accountManager = mockk(relaxUnitFun = true) {
+ every { getAccounts() } returns accountListFlow
+ }
+
+ private val context = mockk {
+ every { lifecycle } returns mockk()
+ }
+
+ private val user1Username = "username"
+
+ private lateinit var viewModel: LauncherViewModel
+
+ @BeforeTest
+ fun before() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+
+ viewModel = buildViewModel()
+ }
+
+ @AfterTest
+ fun teardown() {
+ unmockkStatic(
+ AccountManagerObserver::class,
+ HumanVerificationManagerObserver::class,
+ MissingScopeObserver::class
+ )
+ }
+
+ @Test
+ fun `when no account then AccountNeeded`() = runTest {
+ // GIVEN
+ accountListFlow.emit(emptyList())
+ // WHEN
+ viewModel.state.test {
+ // THEN
+ assertEquals(LauncherState.AccountNeeded, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when all accounts are disabled then AccountNeeded`() = runTest {
+ // GIVEN
+ accountListFlow.emit(listOf(AccountTestData.disabledAccount))
+ // WHEN
+ viewModel.state.test {
+ // THEN
+ assertEquals(LauncherState.AccountNeeded, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when one ready account then PrimaryExist`() = runTest {
+ // GIVEN
+ accountListFlow.emit(listOf(AccountTestData.readyAccount))
+ // WHEN
+ viewModel.state.test {
+ // THEN
+ assertEquals(LauncherState.PrimaryExist, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when adding first account`() = runTest {
+ // GIVEN
+ accountListFlow.emit(emptyList())
+ // WHEN
+ viewModel.state.test {
+ // THEN
+ assertEquals(LauncherState.AccountNeeded, awaitItem())
+
+ accountListFlow.emit(listOf(AccountTestData.notReadyAccount))
+ assertEquals(LauncherState.StepNeeded, awaitItem())
+
+ accountListFlow.emit(listOf(AccountTestData.readyAccount))
+ assertEquals(LauncherState.PrimaryExist, awaitItem())
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when adding a second account PrimaryExist state do not change`() = runTest {
+ // GIVEN
+ accountListFlow.emit(listOf(AccountTestData.readyAccount))
+ // WHEN
+ viewModel.state.test {
+ // THEN
+ assertEquals(LauncherState.PrimaryExist, awaitItem())
+
+ accountListFlow.emit(
+ listOf(AccountTestData.readyAccount, AccountTestData.notReadyAccount)
+ )
+ accountListFlow.emit(
+ listOf(AccountTestData.readyAccount, AccountTestData.readyAccount)
+ )
+
+ val events = cancelAndConsumeRemainingEvents()
+ assertEquals(0, events.size)
+ }
+ }
+
+ @Test
+ fun `when addAccount is called, startAddAccountWorkflow`() = runTest {
+ // WHEN
+ viewModel.submit(LauncherViewModel.Action.AddAccount)
+ // THEN
+ verify {
+ authOrchestrator.startAddAccountWorkflow()
+ }
+ }
+
+ @Test
+ fun `when signIn is called, startLoginWorkflow`() = runTest {
+ // WHEN
+ viewModel.submit(LauncherViewModel.Action.SignIn(userId = null))
+ // THEN
+ verify { authOrchestrator.startLoginWorkflow(any()) }
+ }
+
+ @Test
+ fun `when signIn with userId is called, startLoginWorkflow`() = runTest {
+ // GIVEN
+ every { accountManager.getAccount(userId) } returns flowOf(AccountTestData.readyAccount)
+ // WHEN
+ viewModel.submit(LauncherViewModel.Action.SignIn(userId))
+ // THEN
+ verify { authOrchestrator.startLoginWorkflow(user1Username) }
+ }
+
+ @Test
+ fun `when switch is called on disabled account, startLoginWorkflow`() = runTest {
+ // GIVEN
+ every { accountManager.getAccount(userId) } returns flowOf(AccountTestData.disabledAccount)
+ // WHEN
+ viewModel.submit(LauncherViewModel.Action.Switch(userId))
+ // THEN
+ verify { authOrchestrator.startLoginWorkflow(user1Username) }
+ }
+
+ @Test
+ fun `when switch is called on ready account, setPrimary`() = runTest {
+ // GIVEN
+ every { accountManager.getAccount(userId) } returns flowOf(AccountTestData.readyAccount)
+ // WHEN
+ viewModel.submit(LauncherViewModel.Action.Switch(userId))
+ // THEN
+ coVerify { accountManager.setAsPrimary(userId) }
+ }
+
+ @Test
+ fun `when register is called, verify AccountManagerObserver subscriptions`() = runTest {
+ // GIVEN
+
+ // AccountManager
+ mockkStatic(AccountManager::observe)
+ val amObserver = mockAccountManagerObserver()
+ every { accountManager.observe(any(), any()) } returns amObserver
+
+ // WHEN
+ viewModel.register(context)
+
+ // THEN
+ // AccountManager
+ verify(exactly = 1) { amObserver.onAccountCreateAddressFailed(any(), any()) }
+ verify(exactly = 1) { amObserver.onAccountCreateAddressNeeded(any(), any()) }
+ verify(exactly = 1) { amObserver.onAccountTwoPassModeFailed(any(), any()) }
+ verify(exactly = 1) { amObserver.onAccountDeviceSecretNeeded(any(), any()) }
+ verify(exactly = 1) { amObserver.onAccountTwoPassModeNeeded(any(), any()) }
+ verify(exactly = 1) { amObserver.onSessionSecondFactorNeeded(any(), any()) }
+ }
+
+ @Test
+ fun `when register is called userSettingsOrchestrator is registered`() = runTest {
+ // GIVEN
+ mockkStatic(AccountManager::observe)
+ val amObserver = mockAccountManagerObserver()
+ every { accountManager.observe(any(), any()) } returns amObserver
+
+ // WHEN
+ viewModel.register(context)
+
+ // THEN
+ verify { userSettingsOrchestrator.register(context) }
+ }
+
+ @Test
+ fun `when passwordManagement is called then startPasswordManagementWorkflow`() = runTest {
+ // GIVEN
+ every { accountManager.getPrimaryUserId() } returns flowOf(userId)
+
+ // WHEN
+ viewModel.submit(LauncherViewModel.Action.OpenPasswordManagement)
+
+ // THEN
+ verify { userSettingsOrchestrator.startPasswordManagementWorkflow(userId) }
+ }
+
+ @Test
+ fun `when change recovery email is called, correct workflow is launched`() = runTest {
+ // given
+ every { accountManager.getPrimaryUserId() } returns flowOf(userId)
+
+ // when
+ viewModel.submit(LauncherViewModel.Action.OpenRecoveryEmail)
+
+ // then
+ verify { userSettingsOrchestrator.startUpdateRecoveryEmailWorkflow(userId) }
+ }
+
+ @Test
+ fun `should request notification permission when action is submitted`() {
+ // When
+ viewModel.submit(LauncherViewModel.Action.RequestNotificationPermission)
+
+ // Then
+ verify { notificationPermissionOrchestrator.requestPermissionIfRequired() }
+ }
+
+ private fun buildViewModel() = LauncherViewModel(
+ accountManager,
+ authOrchestrator,
+ notificationPermissionOrchestrator,
+ plansOrchestrator,
+ reportOrchestrator,
+ userSettingsOrchestrator
+ )
+
+ private fun mockAccountManagerObserver(): AccountManagerObserver {
+ mockkStatic(AccountManagerObserver::onAccountCreateAddressFailed)
+ mockkStatic(AccountManagerObserver::onAccountCreateAddressNeeded)
+ mockkStatic(AccountManagerObserver::onAccountTwoPassModeFailed)
+ mockkStatic(AccountManagerObserver::onAccountTwoPassModeNeeded)
+ mockkStatic(AccountManagerObserver::onAccountDeviceSecretNeeded)
+ mockkStatic(AccountManagerObserver::onSessionForceLogout)
+ mockkStatic(AccountManagerObserver::onSessionSecondFactorNeeded)
+ return mockk {
+ every { onAccountCreateAddressFailed(any(), any()) } returns this
+ every { onAccountCreateAddressNeeded(any(), any()) } returns this
+ every { onAccountTwoPassModeFailed(any(), any()) } returns this
+ every { onAccountTwoPassModeNeeded(any(), any()) } returns this
+ every { onAccountDeviceSecretNeeded(any(), any()) } returns this
+ every { onSessionForceLogout(any(), any()) } returns this
+ every { onSessionSecondFactorNeeded(any(), any()) } returns this
+ }
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/OnboardingStepViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/OnboardingStepViewModelTest.kt
new file mode 100644
index 0000000000..1cb11b8e1f
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/navigation/OnboardingStepViewModelTest.kt
@@ -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 .
+ */
+
+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(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)
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/ShareIntentObserverTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/ShareIntentObserverTest.kt
new file mode 100644
index 0000000000..28b2e08f7a
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/navigation/ShareIntentObserverTest.kt
@@ -0,0 +1,140 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation
+
+import android.content.Intent
+import app.cash.turbine.test
+import ch.protonmail.android.mailnotifications.domain.NotificationsDeepLinkHelper
+import ch.protonmail.android.navigation.share.ShareIntentObserver
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class ShareIntentObserverTest {
+
+ private lateinit var shareIntentObserver: ShareIntentObserver
+
+ @Before
+ fun setUp() {
+ shareIntentObserver = ShareIntentObserver()
+ }
+
+ @Test
+ fun `should emit intent for action send`() = runTest {
+ // Given
+ val intent = mockk(relaxed = true) {
+ every { action } returns Intent.ACTION_SEND
+ }
+
+ // When
+ shareIntentObserver.onNewIntent(intent)
+
+ // Then
+ shareIntentObserver().test {
+ val actual = awaitItem()
+ assert(actual == intent)
+ }
+ }
+
+ @Test
+ fun `should emit intent for action view`() = runTest {
+ // Given
+ val intent = mockk(relaxed = true) {
+ every { action } returns Intent.ACTION_VIEW
+ }
+
+ // When
+ shareIntentObserver.onNewIntent(intent)
+
+ // Then
+ shareIntentObserver().test {
+ val actual = awaitItem()
+ assert(actual == intent)
+ }
+ }
+
+ @Test
+ fun `should emit intent for action sendto`() = runTest {
+ // Given
+ val intent = mockk(relaxed = true) {
+ every { action } returns Intent.ACTION_SENDTO
+ }
+
+ // When
+ shareIntentObserver.onNewIntent(intent)
+
+ // Then
+ shareIntentObserver().test {
+ val actual = awaitItem()
+ assert(actual == intent)
+ }
+ }
+
+ @Test
+ fun `should emit intent for action send multiple`() = runTest {
+ // Given
+ val intent = mockk(relaxed = true) {
+ every { action } returns Intent.ACTION_SEND_MULTIPLE
+ }
+
+ // When
+ shareIntentObserver.onNewIntent(intent)
+
+ // Then
+ shareIntentObserver().test {
+ val actual = awaitItem()
+ assert(actual == intent)
+ }
+ }
+
+ @Test
+ fun `should not emit intent for an unhandled action`() = runTest {
+ // Given
+ val intent = mockk(relaxed = true) {
+ every { action } returns Intent.ACTION_APP_ERROR
+ }
+
+ // When
+ shareIntentObserver.onNewIntent(intent)
+
+ // Then
+ shareIntentObserver().test {
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun `should not emit share intent for a notification action`() = runTest {
+ // Given
+ val intent = mockk(relaxed = true) {
+ every { action } returns Intent.ACTION_VIEW
+ every { data?.host } returns NotificationsDeepLinkHelper.NotificationHost
+ }
+
+ // When
+ shareIntentObserver.onNewIntent(intent)
+
+ // Then
+ shareIntentObserver().test {
+ expectNoEvents()
+ }
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinksViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinksViewModelTest.kt
new file mode 100644
index 0000000000..71e53582fe
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinksViewModelTest.kt
@@ -0,0 +1,282 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.navigation.deeplinks
+
+import java.util.UUID
+import app.cash.turbine.test
+import arrow.core.left
+import arrow.core.right
+import ch.protonmail.android.mailcommon.domain.model.DataError
+import ch.protonmail.android.mailcommon.domain.sample.AccountSample
+import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample
+import ch.protonmail.android.mailcommon.domain.usecase.GetPrimaryAddress
+import ch.protonmail.android.mailconversation.domain.repository.ConversationRepository
+import ch.protonmail.android.mailconversation.domain.sample.ConversationSample
+import ch.protonmail.android.mailmessage.domain.model.MessageId
+import ch.protonmail.android.mailmessage.domain.repository.MessageRepository
+import ch.protonmail.android.mailmessage.domain.sample.MessageSample.AlphaAppQAReport
+import ch.protonmail.android.navigation.deeplinks.NotificationsDeepLinksViewModel.State.NavigateToConversation
+import ch.protonmail.android.navigation.deeplinks.NotificationsDeepLinksViewModel.State.NavigateToInbox
+import ch.protonmail.android.navigation.deeplinks.NotificationsDeepLinksViewModel.State.NavigateToMessageDetails
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.domain.entity.UserId
+import me.proton.core.domain.type.IntEnum
+import me.proton.core.mailsettings.domain.entity.MailSettings
+import me.proton.core.mailsettings.domain.entity.ViewMode
+import me.proton.core.mailsettings.domain.repository.MailSettingsRepository
+import me.proton.core.network.domain.NetworkManager
+import me.proton.core.network.domain.NetworkStatus
+import org.junit.Before
+import org.junit.Test
+import kotlin.test.assertEquals
+
+class NotificationsDeepLinksViewModelTest {
+
+ private val networkManager: NetworkManager = mockk {
+ coEvery { networkStatus } returns NetworkStatus.Unmetered
+ }
+ private val accountManager: AccountManager = mockk(relaxed = true)
+ private val messageRepository: MessageRepository = mockk()
+ private val conversationRepository: ConversationRepository = mockk()
+ private val mailSettings: MailSettings = mockk()
+ private val mailSettingsRepository: MailSettingsRepository = mockk {
+ coEvery { getMailSettings(any(), any()) } returns mailSettings
+ }
+ private val getPrimaryAddress: GetPrimaryAddress = mockk()
+
+ @Before
+ fun before() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ }
+
+ @Test
+ fun `Should emit navigate to inbox and cancel the group notification`() = runTest {
+ // Given
+ val viewModel = buildViewModel()
+ val userId = UUID.randomUUID().toString()
+
+ // When
+ viewModel.navigateToInbox(userId)
+
+ // Then
+ viewModel.state.test {
+ assertEquals(NavigateToInbox.ActiveUser, awaitItem())
+ }
+ }
+
+ @Test
+ fun `Should emit navigate to conversation details when conversation mode is enabled`() = runTest {
+ // Given
+ val messageId = UUID.randomUUID().toString()
+ val userId = UUID.randomUUID().toString()
+ coEvery { accountManager.getPrimaryUserId() } returns flowOf(UserId(userId))
+ coEvery { mailSettings.viewMode } returns IntEnum(ViewMode.ConversationGrouping.value, null)
+ coEvery { messageRepository.observeCachedMessage(UserId(userId), MessageId(messageId)) } returns flowOf(
+ AlphaAppQAReport.right()
+ )
+ coEvery {
+ conversationRepository.observeConversation(
+ UserId(userId),
+ AlphaAppQAReport.conversationId,
+ true
+ )
+ } returns flowOf(
+ ConversationSample.AlphaAppFeedback.right()
+ )
+ val viewModel = buildViewModel()
+
+ // When
+ viewModel.navigateToMessage(messageId, userId)
+
+ // Then
+ viewModel.state.test {
+ assertEquals(
+ NavigateToConversation(AlphaAppQAReport.conversationId),
+ awaitItem()
+ )
+ }
+ }
+
+ @Test
+ fun `Should emit navigate to message details when conversation mode is not enabled`() = runTest {
+ // Given
+ val messageId = UUID.randomUUID().toString()
+ val userId = UUID.randomUUID().toString()
+ coEvery { accountManager.getPrimaryUserId() } returns flowOf(UserId(userId))
+ coEvery { mailSettings.viewMode } returns IntEnum(ViewMode.NoConversationGrouping.value, null)
+ coEvery { messageRepository.observeCachedMessage(UserId(userId), MessageId(messageId)) } returns flowOf(
+ AlphaAppQAReport.right()
+ )
+ val viewModel = buildViewModel()
+
+ // When
+ viewModel.navigateToMessage(messageId, userId)
+
+ // Then
+ viewModel.state.test {
+ assertEquals(
+ NavigateToMessageDetails(AlphaAppQAReport.messageId),
+ awaitItem()
+ )
+ }
+ }
+
+ @Test
+ fun `Should emit navigate to inbox when the user is offline and taps in a message deeplink`() = runTest {
+ // Given
+ val messageId = UUID.randomUUID().toString()
+ val userId = UUID.randomUUID().toString()
+ coEvery { networkManager.networkStatus } returns NetworkStatus.Disconnected
+ val viewModel = buildViewModel()
+
+ // When
+ viewModel.navigateToMessage(messageId, userId)
+
+ // Then
+ viewModel.state.test {
+ assertEquals(NavigateToInbox.ActiveUser, awaitItem())
+ }
+ }
+
+ @Test
+ fun `Should navigate to the inbox if there is an error retrieving the local messages`() = runTest {
+ // Given
+ val messageId = UUID.randomUUID().toString()
+ val userId = UUID.randomUUID().toString()
+ coEvery { mailSettings.viewMode } returns IntEnum(ViewMode.NoConversationGrouping.value, null)
+ coEvery { messageRepository.observeCachedMessage(UserId(userId), MessageId(messageId)) } returns flowOf(
+ DataError.Local.Unknown.left()
+ )
+ val viewModel = buildViewModel()
+
+ // When
+ viewModel.navigateToMessage(messageId, userId)
+
+ // Then
+ viewModel.state.test {
+ assertEquals(NavigateToInbox.ActiveUser, awaitItem())
+ }
+ }
+
+ @Test
+ fun `Should navigate to inbox if conversation mode is enabled but the conversation can not be read`() = runTest {
+ // Given
+ val messageId = UUID.randomUUID().toString()
+ val userId = UUID.randomUUID().toString()
+ coEvery { mailSettings.viewMode } returns IntEnum(ViewMode.ConversationGrouping.value, null)
+ coEvery { messageRepository.observeCachedMessage(UserId(userId), MessageId(messageId)) } returns flowOf(
+ AlphaAppQAReport.right()
+ )
+ coEvery {
+ conversationRepository.observeConversation(
+ UserId(userId),
+ AlphaAppQAReport.conversationId,
+ true
+ )
+ } returns flowOf(DataError.Local.Unknown.left())
+ val viewModel = buildViewModel()
+
+ // When
+ viewModel.navigateToMessage(messageId, userId)
+
+ // Then
+ viewModel.state.test {
+ assertEquals(NavigateToInbox.ActiveUser, awaitItem())
+ }
+ }
+
+ @Test
+ fun `Should switch account and emit switched for inbox notification to an active non primary account`() = runTest {
+ // Given
+ val activeAccount = AccountSample.Primary.copy(email = "test@email.com")
+ val notificationUserId = UserId(UUID.randomUUID().toString())
+ val secondaryAccount = AccountSample.Primary.copy(userId = notificationUserId)
+ val viewModel = buildViewModel()
+ coEvery { accountManager.getPrimaryUserId() } returns flowOf(activeAccount.userId)
+ coEvery { accountManager.getAccounts() } returns flowOf(listOf(activeAccount, secondaryAccount))
+ coEvery { getPrimaryAddress.invoke(notificationUserId) } returns UserAddressSample.PrimaryAddress.right()
+
+ // When
+ viewModel.navigateToInbox(notificationUserId.id)
+
+ // Then
+ viewModel.state.test {
+ assertEquals(NavigateToInbox.ActiveUserSwitched(secondaryAccount.email!!), awaitItem())
+ coVerify { accountManager.setAsPrimary(secondaryAccount.userId) }
+ }
+ }
+
+ @Test
+ fun `Should switch account and emit switched for message notification to active non primary account`() = runTest {
+ // Given
+ val activeAccount = AccountSample.Primary.copy(email = "test@email.com")
+ val notificationUserId = UserId(UUID.randomUUID().toString())
+ val secondaryAccount = AccountSample.Primary.copy(userId = notificationUserId)
+ val messageId = UUID.randomUUID().toString()
+ val viewModel = buildViewModel()
+ coEvery { accountManager.getPrimaryUserId() } returns flowOf(activeAccount.userId)
+ coEvery { getPrimaryAddress.invoke(secondaryAccount.userId) } returns UserAddressSample.PrimaryAddress.right()
+ coEvery { accountManager.getAccounts() } returns flowOf(listOf(activeAccount, secondaryAccount))
+ coEvery { mailSettings.viewMode } returns IntEnum(ViewMode.ConversationGrouping.value, null)
+ coEvery {
+ messageRepository.observeCachedMessage(secondaryAccount.userId, any())
+ } returns flowOf(AlphaAppQAReport.right())
+ coEvery {
+ conversationRepository.observeConversation(
+ secondaryAccount.userId,
+ AlphaAppQAReport.conversationId,
+ true
+ )
+ } returns flowOf(
+ ConversationSample.AlphaAppFeedback.right()
+ )
+
+ // When
+ viewModel.navigateToMessage(messageId, secondaryAccount.userId.id)
+
+ // Then
+ viewModel.state.test {
+ assertEquals(
+ NavigateToConversation(
+ conversationId = AlphaAppQAReport.conversationId,
+ userSwitchedEmail = AccountSample.Primary.email
+ ),
+ awaitItem()
+ )
+ coVerify { accountManager.setAsPrimary(secondaryAccount.userId) }
+ }
+ }
+
+ private fun buildViewModel() = NotificationsDeepLinksViewModel(
+ networkManager = networkManager,
+ accountManager = accountManager,
+ messageRepository = messageRepository,
+ conversationRepository = conversationRepository,
+ mailSettingsRepository = mailSettingsRepository,
+ getPrimaryAddress = getPrimaryAddress
+ )
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/outbox/OutboxObserverTest.kt b/app/src/test/kotlin/ch/protonmail/android/outbox/OutboxObserverTest.kt
new file mode 100644
index 0000000000..eecb8c80e4
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/outbox/OutboxObserverTest.kt
@@ -0,0 +1,121 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.outbox
+
+import ch.protonmail.android.initializer.outbox.OutboxObserver
+import ch.protonmail.android.mailcommon.domain.sample.UserIdSample
+import ch.protonmail.android.mailcomposer.domain.usecase.DraftUploadTracker
+import ch.protonmail.android.mailmessage.data.usecase.DeleteSentMessagesFromOutbox
+import ch.protonmail.android.mailmessage.domain.model.DraftAction
+import ch.protonmail.android.mailmessage.domain.model.DraftState
+import ch.protonmail.android.mailmessage.domain.model.DraftSyncState
+import ch.protonmail.android.mailmessage.domain.model.MessageId
+import ch.protonmail.android.mailmessage.domain.repository.OutboxRepository
+import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import me.proton.core.accountmanager.domain.AccountManager
+import me.proton.core.test.kotlin.TestCoroutineScopeProvider
+import me.proton.core.test.kotlin.TestDispatcherProvider
+import kotlin.test.Test
+
+@ExperimentalCoroutinesApi
+class OutboxObserverTest {
+
+ private val userId = UserIdSample.Primary
+ private val unsentDraftItem = DraftState(
+ userId = userId, apiMessageId = MessageId("unsentItem01"),
+ messageId = MessageIdSample.AugWeatherForecast, state = DraftSyncState.Synchronized,
+ action = DraftAction.Compose,
+ sendingError = null,
+ sendingStatusConfirmed = false
+ )
+ private val sentDraftItem = DraftState(
+ userId = userId, apiMessageId = MessageId("sentItem01"),
+ messageId = MessageIdSample.Invoice, state = DraftSyncState.Sent,
+ action = DraftAction.Compose,
+ sendingError = null,
+ sendingStatusConfirmed = false
+ )
+
+ private val accountManager = mockk()
+ private val outboxRepository = mockk()
+ private val deleteSentMessagesFromOutbox = mockk()
+ private val draftUploadTracker = mockk()
+ private val dispatcherProvider = TestDispatcherProvider(UnconfinedTestDispatcher())
+ private val scopeProvider = TestCoroutineScopeProvider(dispatcherProvider)
+
+ private val outboxObserver = OutboxObserver(
+ scopeProvider,
+ accountManager,
+ outboxRepository,
+ deleteSentMessagesFromOutbox,
+ draftUploadTracker
+ )
+
+ @Test
+ fun `should not observe messages when userId is null`() = runTest {
+ // Given
+ coEvery { accountManager.getPrimaryUserId() } returns flowOf(null)
+
+ // When
+ outboxObserver.start()
+
+ // Then
+ coVerify(exactly = 0) { outboxRepository.observeAll(any()) }
+ }
+
+ @Test
+ fun `should not call delete sent outbox messages when there are no outbox messages`() = runTest {
+ // Given
+ coEvery { accountManager.getPrimaryUserId() } returns flowOf(userId)
+ coEvery { outboxRepository.observeAll(any()) } returns flowOf(emptyList())
+
+ // When
+ outboxObserver.start()
+
+ // Then
+ coVerify(exactly = 0) { deleteSentMessagesFromOutbox(userId, any()) }
+ verify(exactly = 0) { draftUploadTracker.notifySentMessages(any()) }
+ }
+
+ @Test
+ fun `should call delete for sent outbox messages and notify draft upload tracker`() = runTest {
+ // Given
+ val outboxDraftItems = flowOf(listOf(unsentDraftItem, sentDraftItem))
+ every { accountManager.getPrimaryUserId() } returns flowOf(userId)
+ coEvery { outboxRepository.observeAll(userId) } returns outboxDraftItems
+ every { draftUploadTracker.notifySentMessages(any()) } returns Unit
+
+ // When
+ outboxObserver.start()
+
+ // Then
+ verify(exactly = 1) { draftUploadTracker.notifySentMessages(setOf(sentDraftItem.messageId)) }
+ coVerify(exactly = 1) { deleteSentMessagesFromOutbox(userId, listOf(sentDraftItem)) }
+ coVerify(exactly = 0) { deleteSentMessagesFromOutbox(userId, listOf(unsentDraftItem)) }
+ }
+}
diff --git a/app/src/test/kotlin/ch/protonmail/android/useragent/BuildUserAgentTest.kt b/app/src/test/kotlin/ch/protonmail/android/useragent/BuildUserAgentTest.kt
new file mode 100644
index 0000000000..7888ab7022
--- /dev/null
+++ b/app/src/test/kotlin/ch/protonmail/android/useragent/BuildUserAgentTest.kt
@@ -0,0 +1,65 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.useragent
+
+import ch.protonmail.android.useragent.model.DeviceData
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Before
+import org.junit.Test
+import kotlin.test.assertEquals
+
+class BuildUserAgentTest {
+ private val versionName = "6.0.0-alpha+fc6081a"
+ private val androidVersion = "12"
+ private val deviceModel = "model"
+ private val deviceBrand = "brand"
+ private val device = "device"
+
+ private val getDeviceData = mockk {
+ every { this@mockk.invoke() } returns DeviceData(device, deviceBrand, deviceModel)
+ }
+ private val getAndroidVersion = mockk {
+ every { this@mockk.invoke() } returns androidVersion
+ }
+ private val getAppVersion = mockk {
+ every { this@mockk.invoke() } returns versionName
+ }
+
+ lateinit var buildUserAgent: BuildUserAgent
+
+ @Before
+ fun setUp() {
+ buildUserAgent = BuildUserAgent(
+ getAppVersion,
+ getAndroidVersion,
+ getDeviceData
+ )
+ }
+
+ @Test
+ fun `builds user agent correctly`() {
+ val actual = buildUserAgent()
+
+ val protonMail = "ProtonMail/$versionName"
+ val android = "Android $androidVersion; $deviceBrand $deviceModel"
+ val expected = "$protonMail ($android)"
+ assertEquals(expected, actual)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/BaseTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/BaseTest.kt
new file mode 100644
index 0000000000..06ff92f72b
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/BaseTest.kt
@@ -0,0 +1,150 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest
+
+import android.content.Context
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.test.core.app.ActivityScenario
+import androidx.test.platform.app.InstrumentationRegistry
+import ch.protonmail.android.BuildConfig
+import ch.protonmail.android.MainActivity
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+import ch.protonmail.android.uitest.rule.GrantNotificationsPermissionRule
+import ch.protonmail.android.uitest.rule.HiltInjectRule
+import ch.protonmail.android.uitest.rule.MainInitializerRule
+import ch.protonmail.android.uitest.rule.MockOnboardingRuntimeRule
+import ch.protonmail.android.uitest.rule.SpotlightSeenRule
+import dagger.hilt.android.testing.HiltAndroidRule
+import kotlinx.coroutines.runBlocking
+import me.proton.core.auth.domain.entity.SessionInfo
+import me.proton.core.auth.domain.testing.LoginTestHelper
+import me.proton.core.configuration.EnvironmentConfiguration
+import me.proton.core.mailsettings.domain.repository.MailSettingsRepository
+import me.proton.core.mailsettings.domain.repository.getMailSettingsOrNull
+import me.proton.core.test.android.instrumented.utils.Shell.setupDeviceForAutomation
+import me.proton.core.test.quark.Quark
+import me.proton.core.test.quark.data.User
+import org.junit.After
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Rule
+import timber.log.Timber
+import javax.inject.Inject
+
+/**
+ * @param logoutUsersOnTearDown by default, revoke the user's session against the API
+ * and logout the user after each test.
+ * If using orchestrator with `clearPackageData` option this might be redundant (might still be
+ * beneficial as it doesn't leave open sessions but doesn't impact the test itself)
+ */
+internal open class BaseTest(
+ private val logoutUsersOnTearDown: Boolean = true
+) {
+
+ @get:Rule(order = RuleOrder_00_First)
+ val hiltRule = HiltAndroidRule(this)
+
+ @get:Rule(order = RuleOrder_10_Initialization)
+ val mainInitializerRule = MainInitializerRule()
+
+ @get:Rule(order = RuleOrder_11_Initialized)
+ val grantNotificationsPermissionRule = GrantNotificationsPermissionRule()
+
+ @get:Rule(order = RuleOrder_20_Injection)
+ val hiltInjectRule = HiltInjectRule(hiltRule)
+
+ @get:Rule(order = RuleOrder_30_ActivityLaunch)
+ val composeTestRule: ComposeTestRule = ComposeTestRuleHolder.createAndGetComposeRule()
+
+ @Inject
+ lateinit var loginTestHelper: LoginTestHelper
+
+ @Inject
+ lateinit var mailSettingsRepo: MailSettingsRepository
+
+ @Inject
+ lateinit var mockOnboardingRuntimeRule: MockOnboardingRuntimeRule
+
+ @Inject
+ lateinit var spotlightSeenRule: SpotlightSeenRule
+
+ @Before
+ open fun setup() {
+ setupDeviceForAutomation(true)
+ loginTestHelper.logoutAll()
+ mockOnboardingRuntimeRule(shouldForceShow = false)
+ spotlightSeenRule.invoke(seen = true)
+
+ ActivityScenario.launch(MainActivity::class.java)
+ }
+
+ @After
+ open fun cleanup() {
+ if (logoutUsersOnTearDown) {
+ Timber.d("Finishing Testing: Revoking user sessions and logging out")
+ loginTestHelper.logoutAll()
+ }
+ }
+
+ fun loginAndAwaitData(user: User) {
+ val sessionInfo = login(user)
+
+ composeTestRule.waitUntil(5_000) {
+ runBlocking { mailSettingsRepo.getMailSettingsOrNull(sessionInfo.userId) != null }
+ }
+ }
+
+ fun login(user: User): SessionInfo {
+ Timber.d("Login user: ${user.name}")
+ return loginTestHelper.login(user.name, user.password)
+ }
+
+ companion object {
+
+ const val RuleOrder_00_First = 0
+ const val RuleOrder_10_Initialization = 10
+ const val RuleOrder_11_Initialized = 11
+ const val RuleOrder_20_Injection = 20
+ const val RuleOrder_21_Injected = 21
+ const val RuleOrder_30_ActivityLaunch = 30
+ const val RuleOrder_31_ActivityLaunched = 31
+ const val RuleOrder_99_Last = 99
+
+ private val context: Context
+ get() = InstrumentationRegistry.getInstrumentation().context
+
+ val users = User.Users.fromJson(
+ json = context.assets.open("users.json").bufferedReader().use { it.readText() }
+ )
+
+ private val envConfig: EnvironmentConfiguration = EnvironmentConfiguration.fromClass()
+
+ val quark = Quark.fromJson(
+ json = context.assets.open("internal_api.json").bufferedReader().use { it.readText() },
+ host = envConfig.host,
+ proxyToken = BuildConfig.PROXY_TOKEN
+ )
+
+ @JvmStatic
+ @BeforeClass
+ fun prepare() {
+ setupDeviceForAutomation(true)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/HiltTestRunner.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/HiltTestRunner.kt
new file mode 100644
index 0000000000..d7b0b50069
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/HiltTestRunner.kt
@@ -0,0 +1,34 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest
+
+import android.app.Application
+import android.content.Context
+import androidx.test.runner.AndroidJUnitRunner
+import dagger.hilt.android.testing.HiltTestApplication
+
+@Suppress("unused") // Used in Gradle config
+internal class HiltTestRunner : AndroidJUnitRunner() {
+
+ override fun newApplication(
+ cl: ClassLoader?,
+ name: String?,
+ context: Context?
+ ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context)
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/MockedNetworkTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/MockedNetworkTest.kt
new file mode 100644
index 0000000000..a0d419ee7d
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/MockedNetworkTest.kt
@@ -0,0 +1,117 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest
+
+import android.Manifest
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.test.rule.GrantPermissionRule
+import ch.protonmail.android.test.idlingresources.ComposeIdlingResource
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+import ch.protonmail.android.uitest.helpers.core.TestIdWatcher
+import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes
+import ch.protonmail.android.uitest.helpers.login.LoginType
+import ch.protonmail.android.uitest.helpers.network.authenticationDispatcher
+import ch.protonmail.android.uitest.rule.GrantNotificationsPermissionRule
+import ch.protonmail.android.uitest.rule.MainInitializerRule
+import ch.protonmail.android.uitest.rule.MockIntentsRule
+import ch.protonmail.android.uitest.rule.MockOnboardingRuntimeRule
+import ch.protonmail.android.uitest.rule.MockTimeRule
+import ch.protonmail.android.uitest.rule.SpotlightSeenRule
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import io.mockk.unmockkObject
+import me.proton.core.network.domain.NetworkManager
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.rules.RuleChain
+import javax.inject.Inject
+
+/**
+ * A base test class used in UI tests that require complete network isolation.
+ *
+ * @param captureIntents whether intents shall be captured (and mocked) for further verification.
+ * @param loginType the login type to use for a given test suite.
+ */
+@HiltAndroidTest
+internal open class MockedNetworkTest(
+ captureIntents: Boolean = true,
+ private val showOnboarding: Boolean = false,
+ private val loginType: LoginType = LoginTestUserTypes.Deprecated.GrumpyCat
+) {
+
+ private val hiltAndroidRule = HiltAndroidRule(this)
+ private val composeTestRule: ComposeTestRule = ComposeTestRuleHolder.createAndGetComposeRule()
+ private val writeExtStoragePermissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+
+ @Inject
+ lateinit var mockWebServer: MockWebServer
+
+ @Inject
+ lateinit var idlingResources: Set<@JvmSuppressWildcards ComposeIdlingResource>
+
+ @Inject
+ lateinit var networkManager: NetworkManager
+
+ @Inject
+ lateinit var mockOnboardingRuntimeRule: MockOnboardingRuntimeRule
+
+ @Inject
+ lateinit var spotlightSeenRule: SpotlightSeenRule
+
+ @get:Rule
+ val ruleChain: RuleChain = RuleChain.outerRule(
+ hiltAndroidRule
+ ).around(
+ composeTestRule
+ ).around(
+ writeExtStoragePermissionRule
+ ).around(
+ GrantNotificationsPermissionRule()
+ ).around(
+ MockIntentsRule(captureIntents)
+ ).around(
+ MainInitializerRule()
+ ).around(
+ MockTimeRule()
+ ).around(
+ TestIdWatcher()
+ )
+
+ @Before
+ fun setup() {
+ hiltAndroidRule.inject()
+
+ idlingResources.forEach { idlingResource ->
+ idlingResource.clear()
+ composeTestRule.registerIdlingResource(idlingResource)
+ }
+
+ mockWebServer.dispatcher = authenticationDispatcher(loginType)
+ mockOnboardingRuntimeRule(showOnboarding)
+ spotlightSeenRule.invoke(seen = true)
+ }
+
+ @After
+ fun tearDown() {
+ idlingResources.forEach { composeTestRule.unregisterIdlingResource(it) }
+ unmockkObject(networkManager)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/CoreBaseNetworkTestModule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/CoreBaseNetworkTestModule.kt
new file mode 100644
index 0000000000..aa6c3ae093
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/CoreBaseNetworkTestModule.kt
@@ -0,0 +1,77 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.di
+
+import java.security.SecureRandom
+import java.security.cert.X509Certificate
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.Reusable
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import dagger.hilt.testing.TestInstallIn
+import io.mockk.spyk
+import me.proton.core.network.dagger.CoreBaseNetworkModule
+import me.proton.core.network.data.NetworkManager
+import me.proton.core.network.data.di.SharedOkHttpClient
+import me.proton.core.network.domain.NetworkManager
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockWebServer
+import javax.inject.Singleton
+import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLSocketFactory
+import javax.net.ssl.X509TrustManager
+
+/**
+ * A test module that overrides the [CoreBaseNetworkModule] to allow HTTPS connections between the app and
+ * the local [MockWebServer] with a customized [OkHttpClient].
+ *
+ * The provided [NetworkManager] is the same as the one currently used in production code.
+ */
+@Module
+@TestInstallIn(components = [SingletonComponent::class], replaces = [CoreBaseNetworkModule::class])
+class CoreBaseNetworkTestModule {
+
+ private val testX509TrustManager = object : X509TrustManager {
+ override fun checkClientTrusted(p0: Array?, p1: String?) = Unit
+ override fun checkServerTrusted(p0: Array?, p1: String?) = Unit
+ override fun getAcceptedIssuers(): Array = arrayOf()
+ }
+
+ @Provides
+ @Reusable
+ @TestClientSSLSocketFactory
+ internal fun provideTestClientSSLSocketFactory(): SSLSocketFactory {
+ return SSLContext.getInstance("TLS").apply {
+ init(null, arrayOf(testX509TrustManager), SecureRandom())
+ }.socketFactory
+ }
+
+ @Provides
+ @Singleton
+ @SharedOkHttpClient
+ internal fun provideOkHttpClient(@TestClientSSLSocketFactory sslSocketFactory: SSLSocketFactory): OkHttpClient =
+ OkHttpClient.Builder().sslSocketFactory(sslSocketFactory, testX509TrustManager).build()
+
+ @Provides
+ @Singleton
+ internal fun provideNetworkManager(@ApplicationContext context: Context): NetworkManager =
+ spyk(NetworkManager(context))
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/LocalhostApiModule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/LocalhostApiModule.kt
new file mode 100644
index 0000000000..dbf3e2928d
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/LocalhostApiModule.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.hilt.testing.TestInstallIn
+import javax.inject.Qualifier
+
+/**
+ * Convenient annotation to ease the injection of the [Boolean] flag indicating whether we want to force
+ * the use of localhost when resolving the base URL for UI Tests.
+ */
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class LocalhostApi
+
+/**
+ * This is a custom test module that determines whether the API calls shall be mocked in UI Tests.
+ *
+ * Since we can't use [TestInstallIn] multiple times in our tests and we don't want to bloat the test suites with
+ * multiple [InstallIn], [LocalhostApi] is introduced to set whether tests should run in complete network isolation.
+ */
+@Module
+@InstallIn(SingletonComponent::class)
+object LocalhostApiModule {
+
+ @Provides
+ @LocalhostApi
+ fun useLocalhostApi(): Boolean = true
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/NetworkConfigTestModule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/NetworkConfigTestModule.kt
new file mode 100644
index 0000000000..31c5641301
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/NetworkConfigTestModule.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.di
+
+import ch.protonmail.android.di.NetworkConfigModule
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.components.SingletonComponent
+import dagger.hilt.testing.TestInstallIn
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import me.proton.core.configuration.EnvironmentConfiguration
+import me.proton.core.network.data.di.BaseProtonApiUrl
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.mockwebserver.MockWebServer
+
+/**
+ * A test module used to override the [BaseProtonApiUrl] in UI Tests.
+ */
+@Module
+@TestInstallIn(components = [SingletonComponent::class], replaces = [NetworkConfigModule::class])
+object NetworkConfigTestModule {
+
+ @Provides
+ @BaseProtonApiUrl
+ fun provideBaseProtonApiUrl(
+ @LocalhostApi localhostApi: Boolean,
+ mockWebServer: MockWebServer,
+ envConfig: EnvironmentConfiguration
+ ): HttpUrl {
+ return if (localhostApi) {
+ runBlocking {
+ withContext(Dispatchers.IO) {
+ mockWebServer.url("/")
+ }
+ }
+ } else {
+ // This is a temporary solution until we come up with an efficient environment switch.
+ envConfig.baseUrl.toHttpUrl()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/TestClientSSLSocketFactory.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/TestClientSSLSocketFactory.kt
new file mode 100644
index 0000000000..6b5e955f23
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/TestClientSSLSocketFactory.kt
@@ -0,0 +1,31 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.di
+
+import okhttp3.mockwebserver.MockWebServer
+import javax.inject.Qualifier
+import javax.net.ssl.SSLSocketFactory
+
+/**
+ * Annotation used to inject a client-specific [SSLSocketFactory] instance in UI Tests to enable HTTPS
+ * communication between the app and the local [MockWebServer].
+ */
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class TestClientSSLSocketFactory
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/account/AddAccountRobotProxy.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/account/AddAccountRobotProxy.kt
new file mode 100644
index 0000000000..66bcfce5b7
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/account/AddAccountRobotProxy.kt
@@ -0,0 +1,34 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.account
+
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import ch.protonmail.android.R
+import me.proton.core.test.android.robots.auth.AddAccountRobot
+
+fun addAccountRobot(block: AddAccountRobot.() -> Unit) = AddAccountRobot().apply(block)
+
+fun AddAccountRobot.Verify.isDisplayed() {
+ // There are no efficient ways to check for this since it does not have a root view id.
+ onView(withId(R.id.sign_in)).check(matches(ViewMatchers.isDisplayed()))
+ onView(withId(R.id.sign_up)).check(matches(ViewMatchers.isDisplayed()))
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/account/SignOutAccountTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/account/SignOutAccountTest.kt
new file mode 100644
index 0000000000..d300b7de0b
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/account/SignOutAccountTest.kt
@@ -0,0 +1,84 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.account
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.account.section.buttonsSection
+import ch.protonmail.android.uitest.robot.account.signOutAccountDialogRobot
+import ch.protonmail.android.uitest.robot.account.verify
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.verify
+import ch.protonmail.android.uitest.robot.menu.menuRobot
+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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class SignOutAccountTest : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Before
+ fun navigateToLogout() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher()
+ navigator { navigateTo(Destination.SidebarMenu) }
+ menuRobot { tapSignOut() }
+ }
+
+ @Test
+ @TestId("256595")
+ fun testSignOutIsPerformedOnDialogConfirmationWhenSingleAccountLoggedIn() {
+ signOutAccountDialogRobot {
+ buttonsSection { tapSignOut() }
+ // Do not call isNotShown() here as it transitions to an external non-compose screen.
+ }
+
+ addAccountRobot {
+ verify { isDisplayed() }
+ }
+ }
+
+ @Test
+ @TestId("256596")
+ fun testSignOutIsNotPerformedOnDialogCancellationWhenSingleAccountLoggedIn() {
+ signOutAccountDialogRobot {
+ buttonsSection { tapCancel() }
+ verify { isNotShown() }
+ }
+
+ mailboxRobot {
+ verify { isShown() }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/accountrecovery/AccountRecoveryFlowTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/accountrecovery/AccountRecoveryFlowTest.kt
new file mode 100644
index 0000000000..873b6f12e6
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/accountrecovery/AccountRecoveryFlowTest.kt
@@ -0,0 +1,93 @@
+package ch.protonmail.android.uitest.e2e.accountrecovery
+
+import ch.protonmail.android.test.annotations.suite.CoreLibraryTest
+import ch.protonmail.android.uitest.BaseTest
+import ch.protonmail.android.uitest.di.LocalhostApi
+import ch.protonmail.android.uitest.di.LocalhostApiModule
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.verify
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.UninstallModules
+import me.proton.core.accountmanager.data.AccountStateHandler
+import me.proton.core.accountrecovery.dagger.CoreAccountRecoveryFeaturesModule
+import me.proton.core.accountrecovery.domain.IsAccountRecoveryEnabled
+import me.proton.core.accountrecovery.domain.IsAccountRecoveryResetEnabled
+import me.proton.core.accountrecovery.test.MinimalAccountRecoveryNotificationTest
+import me.proton.core.auth.test.flow.SignInFlow
+import me.proton.core.auth.test.robot.AddAccountRobot
+import me.proton.core.auth.test.usecase.WaitForPrimaryAccount
+import me.proton.core.domain.entity.UserId
+import me.proton.core.eventmanager.domain.EventManagerProvider
+import me.proton.core.eventmanager.domain.repository.EventMetadataRepository
+import me.proton.core.network.data.ApiProvider
+import me.proton.core.notification.dagger.CoreNotificationFeaturesModule
+import me.proton.core.notification.domain.repository.NotificationRepository
+import me.proton.core.notification.domain.usecase.IsNotificationsEnabled
+import me.proton.core.test.android.instrumented.FusionConfig
+import javax.inject.Inject
+
+@CoreLibraryTest
+@HiltAndroidTest
+@UninstallModules(
+ LocalhostApiModule::class,
+ CoreAccountRecoveryFeaturesModule::class,
+ CoreNotificationFeaturesModule::class
+)
+internal class AccountRecoveryFlowTest : BaseTest(), MinimalAccountRecoveryNotificationTest {
+ @JvmField
+ @BindValue
+ @LocalhostApi
+ val localhostApi = false
+
+ @Inject
+ override lateinit var accountStateHandler: AccountStateHandler
+
+ @Inject
+ override lateinit var apiProvider: ApiProvider
+
+ @Inject
+ override lateinit var eventManagerProvider: EventManagerProvider
+
+ @Inject
+ override lateinit var eventMetadataRepository: EventMetadataRepository
+
+ @Inject
+ override lateinit var notificationRepository: NotificationRepository
+
+ @Inject
+ override lateinit var waitForPrimaryAccount: WaitForPrimaryAccount
+
+ @BindValue
+ internal val isAccountRecoveryEnabled = object : IsAccountRecoveryEnabled {
+ override fun invoke(userId: UserId?): Boolean = true
+ override fun isLocalEnabled(): Boolean = true
+ override fun isRemoteEnabled(userId: UserId?): Boolean = true
+ }
+
+ @BindValue
+ internal val isAccountRecoveryResetEnabled = object : IsAccountRecoveryResetEnabled {
+ override fun invoke(userId: UserId?): Boolean = true
+ override fun isLocalEnabled(): Boolean = true
+ override fun isRemoteEnabled(userId: UserId?): Boolean = true
+ }
+
+ @BindValue
+ internal val isNotificationsEnabled = IsNotificationsEnabled { true }
+
+ init {
+ FusionConfig.Compose.testRule = composeTestRule
+ }
+
+ override fun setup() {
+ super.setup()
+ val user = users.getUser { it.name == "pro" }
+
+ AddAccountRobot.clickSignIn()
+ SignInFlow.signInInternal(user.name, user.password)
+ }
+
+ override fun verifyAfterLogin() {
+ mailboxRobot { verify { isShown() } }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/ComposerMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/ComposerMainTests.kt
new file mode 100644
index 0000000000..ce6921b1a5
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/ComposerMainTests.kt
@@ -0,0 +1,202 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.robot.common.section.keyboardSection
+import ch.protonmail.android.uitest.robot.common.section.verify
+import ch.protonmail.android.uitest.robot.composer.composerRobot
+import ch.protonmail.android.uitest.robot.composer.section.messageBodySection
+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
+import ch.protonmail.android.uitest.robot.composer.section.recipients.verify
+import ch.protonmail.android.uitest.robot.composer.section.senderSection
+import ch.protonmail.android.uitest.robot.composer.section.subjectSection
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.composer.section.verify
+import ch.protonmail.android.uitest.robot.composer.verify
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.verify
+import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice
+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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerMainTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), ComposerTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Before
+ fun setMockDispatcher() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ }
+
+ @Test
+ @TestId("79036")
+ fun checkComposerMainFieldsAndInteractions() {
+ val expectedSender = "fancycapybara@proton.black"
+ val expectedRecipient = "test@example.com"
+ val expectedSubject = "Subject"
+ val typedBody = "Text message"
+ val expectedBody = "$typedBody\n\nSent from Proton Mail Android"
+
+ navigator {
+ navigateTo(Destination.Composer)
+ }
+
+ composerRobot {
+ verify { composerIsShown() }
+
+ // Sender field
+ senderSection {
+ verify { hasValue(expectedSender) }
+ }
+
+ keyboardSection {
+ verify { keyboardIsShown() }
+ }
+
+ // Recipient field
+ toRecipientSection {
+ verify {
+ isFieldFocused()
+ isEmptyField()
+ }
+
+ typeRecipient(expectedRecipient)
+ verify { hasRecipient(expectedRecipient) }
+ }
+
+ // Subject field
+ subjectSection {
+ verify { hasEmptySubject() }
+ typeSubject(expectedSubject)
+ verify { hasSubject(expectedSubject) }
+ }
+
+ // Message body field
+ messageBodySection {
+ typeMessageBody(typedBody)
+ verify { hasText(expectedBody) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("79037")
+ fun checkComposerCloseNavigation() {
+ navigator {
+ navigateTo(Destination.Composer)
+ }
+
+ composerRobot {
+ topAppBarSection { tapCloseButton() }
+ }
+
+ mailboxRobot {
+ verify { isShown() }
+ }
+ }
+
+ @Test
+ @TestId("79038")
+ fun checkComposerBackButtonNavigation() {
+ navigator {
+ navigateTo(Destination.Composer)
+ }
+
+ composerRobot {
+ keyboardSection { dismissKeyboard() }
+ }
+
+ uiDevice.pressBack()
+
+ mailboxRobot {
+ verify { isShown() }
+ }
+ }
+
+ @Test
+ @TestId("79039")
+ fun checkComposerKeyboardDismissalWithBackButton() {
+ navigator {
+ navigateTo(Destination.Composer)
+ }
+
+ composerRobot {
+ keyboardSection {
+ dismissKeyboard()
+
+ verify { keyboardIsNotShown() }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190226", "190227")
+ fun testCollapseExpandChevron() {
+ navigator {
+ navigateTo(Destination.Composer)
+ }
+
+ composerRobot {
+ toRecipientSection {
+ expandCcAndBccFields()
+ verify { isEmptyField() }
+ }
+
+ ccRecipientSection {
+ verify { isEmptyField() }
+ }
+
+ bccRecipientSection {
+ verify { isEmptyField() }
+ }
+
+ toRecipientSection {
+ hideCcAndBccFields()
+ }
+
+ ccRecipientSection {
+ verify { isHidden() }
+ }
+
+ bccRecipientSection {
+ verify { isHidden() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/ComposerTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/ComposerTests.kt
new file mode 100644
index 0000000000..1311e06600
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/ComposerTests.kt
@@ -0,0 +1,181 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer
+
+import ch.protonmail.android.networkmocks.mockwebserver.MockNetworkDispatcher
+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.post
+import ch.protonmail.android.networkmocks.mockwebserver.requests.put
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.composer.ComposerRobot
+import ch.protonmail.android.uitest.robot.composer.composerRobot
+import ch.protonmail.android.uitest.robot.composer.section.messageBodySection
+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
+import ch.protonmail.android.uitest.robot.composer.section.subjectSection
+import ch.protonmail.android.uitest.robot.helpers.mockRobot
+import ch.protonmail.android.uitest.robot.helpers.section.time
+
+internal interface ComposerTests {
+
+ fun composerMockNetworkDispatcher(
+ useDefaultMailSettings: Boolean = true,
+ useDefaultMessagesList: Boolean = true,
+ useDefaultContacts: Boolean = true,
+ useDefaultDraftUploadResponse: Boolean = false,
+ useDefaultSendMessageResponse: Boolean = false,
+ useDefaultRecipientKeys: Boolean = true,
+ mockDefinitions: MockNetworkDispatcher.() -> Unit = {}
+ ) = mockNetworkDispatcher(
+ useDefaultMailSettings = useDefaultMailSettings,
+ useDefaultContacts = useDefaultContacts
+ ) {
+
+ if (useDefaultMessagesList) {
+ addMockRequests(
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ if (useDefaultDraftUploadResponse) {
+ addMockRequests(
+ post("/mail/v4/messages")
+ respondWith "/mail/v4/messages/post/post_messages_base_create_placeholder.json"
+ withStatusCode 200 serveOnce true,
+ put("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/put/put_messages_base_placeholder.json"
+ withStatusCode 200 matchWildcards true,
+ )
+ }
+
+ if (useDefaultSendMessageResponse) {
+ addMockRequests(
+ post("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/post/post_messages_base_send_placeholder.json"
+ withStatusCode 200 matchWildcards true serveOnce true withNetworkDelay 500L
+ )
+ }
+
+ if (useDefaultRecipientKeys) {
+ addMockRequests(
+ get("/core/v4/keys?Email=royalcat%40proton.black")
+ respondWith "/core/v4/keys/keys_royalcat.json"
+ withStatusCode 200 serveOnce true,
+ get("/core/v4/keys?Email=royaldog%40proton.black")
+ respondWith "/core/v4/keys/keys_royaldog.json"
+ withStatusCode 200 serveOnce true,
+ get("/core/v4/keys?Email=specialfox%40proton.black")
+ respondWith "/core/v4/keys/keys_specialfox.json"
+ withStatusCode 200 serveOnce true,
+ get("/core/v4/keys?Email=sleepykoala%40proton.black")
+ respondWith "/core/v4/keys/keys_sleepykoala.json"
+ withStatusCode 200 serveOnce true,
+ get("/core/v4/keys?Email=strangewalrus%40proton.black")
+ respondWith "/core/v4/keys/keys_strangewalrus.json"
+ withStatusCode 200 serveOnce true,
+ get("/core/v4/keys?Email=happyllama%40proton.black")
+ respondWith "/core/v4/keys/keys_happyllama.json"
+ withStatusCode 200 serveOnce true,
+ get("/core/v4/keys?Email=test%40example.com")
+ respondWith "/core/v4/keys/keys_testexample.json"
+ withStatusCode 200 serveOnce true,
+ get("/core/v4/keys?Email=test2%40example.com")
+ respondWith "/core/v4/keys/keys_test2example.json"
+ withStatusCode 200 serveOnce true,
+ get("/core/v4/keys?Email=test3%40example.com")
+ respondWith "/core/v4/keys/keys_test3example.json"
+ withStatusCode 200 serveOnce true,
+ get("/core/v4/keys?Email=test4%40example.com")
+ respondWith "/core/v4/keys/keys_test4example.json"
+ withStatusCode 200 serveOnce true,
+ get("/core/v4/keys?Email=test5%40example.com")
+ respondWith "/core/v4/keys/keys_test5example.json"
+ withStatusCode 200 serveOnce true,
+ get("/core/v4/keys?Email=test6%40example.com")
+ respondWith "/core/v4/keys/keys_test6example.json"
+ withStatusCode 200 serveOnce true
+ )
+ }
+
+ mockDefinitions()
+ }
+
+ fun ComposerRobot.prepareDraft(
+ toRecipient: String? = null,
+ ccRecipient: String? = null,
+ bccRecipient: String? = null,
+ subject: String? = null,
+ body: String? = null
+ ) {
+ prepareDraft(
+ toRecipient?.let { listOf(it) } ?: emptyList(),
+ ccRecipient?.let { listOf(it) } ?: emptyList(),
+ bccRecipient?.let { listOf(it) } ?: emptyList(),
+ subject,
+ body
+ )
+ }
+
+ fun ComposerRobot.prepareDraft(
+ toRecipients: List = emptyList(),
+ ccRecipients: List = emptyList(),
+ bccRecipients: List = emptyList(),
+ subject: String? = null,
+ body: String? = null
+ ) {
+ mockRobot {
+ time { forceCurrentMillisTo(1_688_211_755) } // Jul 1st, 2023
+ }
+
+ composerRobot {
+ toRecipientSection {
+ toRecipients.forEach { typeRecipient(it, autoConfirm = true) }
+ }
+
+ if (ccRecipients.isNotEmpty() || bccRecipients.isNotEmpty()) {
+ toRecipientSection { expandCcAndBccFields() }
+ }
+
+ ccRecipientSection {
+ ccRecipients.forEach { typeRecipient(it, autoConfirm = true) }
+ }
+
+ bccRecipientSection {
+ bccRecipients.forEach { typeRecipient(it, autoConfirm = true) }
+ }
+
+ subject?.let {
+ subjectSection { typeSubject(it) }
+ }
+
+ body?.let {
+ messageBodySection { typeMessageBody(body) }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerAttachmentsButtonTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerAttachmentsButtonTests.kt
new file mode 100644
index 0000000000..1de99a01ac
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerAttachmentsButtonTests.kt
@@ -0,0 +1,115 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.attachments
+
+import java.time.Instant
+import androidx.test.filters.SdkSuppress
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.composer.composerRobot
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+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
+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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@SdkSuppress(minSdkVersion = 30, maxSdkVersion = 32)
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerAttachmentsButtonTests : MockedNetworkTest(), ComposerAttachmentsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private lateinit var attachmentName: String
+ private val defaultExpectedEntry: AttachmentDetailItemEntry
+ get() = AttachmentDetailItemEntry(
+ index = 0,
+ fileName = attachmentName,
+ fileSize = "78 kB",
+ hasDeleteIcon = true
+ )
+
+ @Before
+ fun setupTests() {
+ attachmentName = "${Instant.now().epochSecond}.jpg"
+ val uri = initFakeFileUri("placeholder_image.jpg", attachmentName, "image/jpg")
+ stubPickerActivityResultWithUri(uri)
+
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher()
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @Test
+ @TestId("226087", "226088")
+ fun testMainAttachmentsButtonInteractions() {
+ composerRobot {
+ topAppBarSection { tapAttachmentsButton() }
+ }
+
+ deviceRobot {
+ intents { verify { filePickerIntentWasLaunched() } }
+ }
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 29)
+ @TestId("226090")
+ fun testAttachmentChipEntryUponPicking() {
+ composerRobot {
+ topAppBarSection { tapAttachmentsButton() }
+ attachmentsSection { verify { hasAttachments(defaultExpectedEntry) } }
+ }
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 29)
+ @TestId("226091")
+ fun testAttachmentChipDuplicateEntryUponPicking() {
+ val expectedEntries = arrayOf(
+ defaultExpectedEntry,
+ defaultExpectedEntry.copy(index = 1)
+ )
+
+ composerRobot {
+ topAppBarSection { tapAttachmentsButton() }
+ attachmentsSection { verify { hasAttachments(defaultExpectedEntry) } }
+
+ topAppBarSection { tapAttachmentsButton() }
+ attachmentsSection { verify { hasAttachments(*expectedEntries) } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerAttachmentsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerAttachmentsTests.kt
new file mode 100644
index 0000000000..701621f58a
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerAttachmentsTests.kt
@@ -0,0 +1,80 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.attachments
+
+import android.app.Activity
+import android.app.Instrumentation
+import android.content.ContentValues
+import android.content.Intent
+import android.net.Uri
+import android.provider.MediaStore
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.intent.matcher.IntentMatchers
+import ch.protonmail.android.networkmocks.assets.RawAssets
+import ch.protonmail.android.uitest.e2e.composer.ComposerTests
+import ch.protonmail.android.uitest.util.InstrumentationHolder
+
+internal interface ComposerAttachmentsTests : ComposerTests {
+
+ /**
+ * Pushes a file from internal assets into the Download directory of the targeted device
+ * and returns the newly created file's URI.
+ *
+ * @param testAssetFileName the file name provided by internal assets.
+ * @param downloadDirFileName the name of the locally copied file.
+ * @param mimeType the MIME type of the file.
+ *
+ * @return the created file URI.
+ */
+ fun initFakeFileUri(testAssetFileName: String, downloadDirFileName: String, mimeType: String): Uri {
+ val contentResolver = InstrumentationHolder.instrumentation.targetContext.contentResolver
+
+ val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, ContentValues().apply {
+ put(MediaStore.Downloads.DISPLAY_NAME, downloadDirFileName)
+ put(MediaStore.Downloads.MIME_TYPE, mimeType)
+ })
+
+ requireNotNull(uri) { "Generated file Uri is null" } // `null` should never happen
+
+ val assetPath = "$RawAssetsRootDir/$testAssetFileName"
+ val outputStream = requireNotNull(contentResolver.openOutputStream(uri, "w")) { "Unable to open output stream" }
+ val fileInputStream =
+ requireNotNull(RawAssets.getInputStreamForPath(assetPath)) { "Unable to open input stream for path $assetPath" }
+
+ fileInputStream.use { outputStream.write(it.readBytes()) }
+
+ return uri
+ }
+
+ /**
+ * Stubs the File Picker (or similar intent actions) Activity Result by passing the given [Uri] as the [Intent] data.
+ *
+ * @param uri the file URI that needs to be returned
+ */
+ fun stubPickerActivityResultWithUri(uri: Uri) {
+ val intent = Intent().apply { data = uri }
+ val result = Instrumentation.ActivityResult(Activity.RESULT_OK, intent)
+ Intents.intending(IntentMatchers.hasAction(Intent.ACTION_GET_CONTENT)).respondWith(result)
+ }
+
+ private companion object {
+
+ val RawAssetsRootDir = "assets/raw"
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerSendMessageWithAttachmentsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerSendMessageWithAttachmentsTests.kt
new file mode 100644
index 0000000000..a111ebec0f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerSendMessageWithAttachmentsTests.kt
@@ -0,0 +1,170 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.attachments
+
+import java.time.Instant
+import androidx.test.filters.SdkSuppress
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.post
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.simulateNoNetwork
+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.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.composerRobot
+import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar
+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
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.UninstallModules
+import io.mockk.every
+import io.mockk.mockk
+import me.proton.core.auth.domain.usecase.ValidateServerProof
+import org.junit.Before
+import kotlin.test.Test
+
+@RegressionTest
+@HiltAndroidTest
+@SdkSuppress(minSdkVersion = 30, maxSdkVersion = 32)
+@UninstallModules(ServerProofModule::class)
+internal class ComposerSendMessageWithAttachmentsTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara),
+ ComposerAttachmentsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val protonRecipient = "sleepykoala@proton.black"
+ private val subject = "Test subject"
+ private val body = "Test body"
+
+ @Before
+ fun setupTests() {
+ val attachmentName = Instant.now().epochSecond.toString()
+ val uri = initFakeFileUri("placeholder_image.jpg", attachmentName, "image/jpg")
+ stubPickerActivityResultWithUri(uri)
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("226734")
+ fun testAttachmentSendingToProtonMailAddress() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = true,
+ useDefaultSendMessageResponse = true
+ ) {
+ addMockRequests(
+ post("/mail/v4/attachments")
+ respondWith "/mail/v4/attachments/attachments_226734.json"
+ withStatusCode 200
+ )
+ }
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ prefillMessageWithAttachment()
+ topAppBarSection { tapSendButton() }
+ }
+
+ mailboxRobot {
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.SendingMessage) } }
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSent) } }
+ }
+ }
+
+ @Test
+ @TestId("226735")
+ fun testAttachmentSendingToProtonMailAddressWithError() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = true,
+ useDefaultSendMessageResponse = false
+ ) {
+ addMockRequests(
+ post("/mail/v4/attachments")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503
+ )
+ }
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ prefillMessageWithAttachment()
+ topAppBarSection { tapSendButton() }
+ }
+
+ mailboxRobot {
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.SendingMessage) } }
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } }
+ }
+ }
+
+ @Test
+ @TestId("226736")
+ fun testAttachmentSendingToProtonMailAddressWhenOfflineError() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = true,
+ useDefaultSendMessageResponse = false
+ ) {
+ addMockRequests(
+ post("/mail/v4/attachments")
+ simulateNoNetwork true
+ )
+ }
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ prefillMessageWithAttachment()
+ topAppBarSection { tapSendButton() }
+ }
+
+ mailboxRobot {
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.SendingMessage) } }
+
+ // Deferred as on FTL it might propagate too quickly.
+ every { networkManager.isConnectedToNetwork() } returns false
+
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } }
+ }
+ }
+
+ private fun ComposerRobot.prefillMessageWithAttachment() {
+ toRecipientSection { typeRecipient(protonRecipient, autoConfirm = true) }
+ subjectSection { typeSubject(subject) }
+ messageBodySection { typeMessageBody(body) }
+
+ topAppBarSection { tapAttachmentsButton() }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerChipsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerChipsTests.kt
new file mode 100644
index 0000000000..3ae8087d58
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerChipsTests.kt
@@ -0,0 +1,73 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.chips
+
+import ch.protonmail.android.uitest.e2e.composer.ComposerTests
+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.section.recipients.ComposerRecipientsSection
+import ch.protonmail.android.uitest.robot.composer.section.recipients.verify
+
+internal interface ComposerChipsTests : ComposerTests {
+
+ fun ComposerRecipientsSection.createAndVerifyChip(
+ state: RecipientChipValidationState,
+ trigger: ChipsCreationTrigger = ChipsCreationTrigger.ImeAction
+ ) {
+ val text = when (state) {
+ is RecipientChipValidationState.Valid -> "rec@ipient.com"
+ is RecipientChipValidationState.Invalid -> "test"
+ }
+
+ val chip = RecipientChipEntry(
+ index = 0, text = text, state = state
+ )
+
+ typeRecipient(chip.text)
+ triggerChipCreation(trigger)
+
+ verify {
+ hasRecipientChips(
+ chip.copy(hasDeleteIcon = true)
+ )
+ }
+ }
+
+ fun withMultipleRecipients(
+ size: Int,
+ state: RecipientChipValidationState,
+ block: (RecipientChipEntry) -> Any
+ ) {
+ (0 until size).forEach { index ->
+ val recipient = StringBuilder("test$index").apply {
+ if (state == RecipientChipValidationState.Valid) append("@email.com")
+ }.toString()
+
+ val recipientChipEntry = RecipientChipEntry(
+ index = index,
+ text = recipient,
+ hasDeleteIcon = true,
+ state = state
+ )
+
+ block(recipientChipEntry)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsChipsDeletionTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsChipsDeletionTests.kt
new file mode 100644
index 0000000000..7d5ce95e65
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsChipsDeletionTests.kt
@@ -0,0 +1,322 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.chips
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.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.section.recipients.ComposerRecipientsSection
+import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection
+import ch.protonmail.android.uitest.robot.composer.section.recipients.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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerRecipientsChipsDeletionTests : MockedNetworkTest(), ComposerChipsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Before
+ fun navigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("190261")
+ fun testValidChipDeletion() {
+ val expectedValidChip = RecipientChipEntry(
+ index = 0,
+ text = "delete@me.com",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Valid
+ )
+
+ composerRobot {
+ toRecipientSection {
+ validateSimpleChipCreationAndDeletion(expectedValidChip)
+ }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("190262")
+ fun testInvalidChipDeletion() {
+ val expectedInvalidChip = RecipientChipEntry(
+ index = 0,
+ text = "deleteme.com",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Invalid
+ )
+
+ composerRobot {
+ toRecipientSection {
+ validateSimpleChipCreationAndDeletion(expectedInvalidChip)
+ }
+ }
+ }
+
+ @Test
+ @TestId("190263")
+ fun testMultipleChipsLastChipDeletion() {
+ val expectedFinalChips = arrayOf(
+ RecipientChipEntry(
+ index = 0,
+ text = "one",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Invalid
+ ),
+ RecipientChipEntry(
+ index = 1,
+ text = "two@test.com",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Valid
+ )
+ )
+
+ val expectedChip = RecipientChipEntry(
+ index = 2,
+ text = "delete@me.com",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Valid
+ )
+
+ composerRobot {
+ toRecipientSection {
+ typeMultipleRecipients(expectedFinalChips[0].text, expectedFinalChips[1].text)
+ validateSimpleChipCreationAndDeletion(expectedChip)
+ verify { hasRecipientChips(*expectedFinalChips) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190264")
+ fun testMultipleChipsFirstChipDeletion() {
+ val toBeDeletedChipText = "additional@chip.com"
+ val expectedFinalChips = arrayOf(
+ RecipientChipEntry(
+ index = 0,
+ text = "one",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Invalid
+ ),
+ RecipientChipEntry(
+ index = 1,
+ text = "two@test.com",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Valid
+ )
+ )
+
+ composerRobot {
+ toRecipientSection {
+ typeMultipleRecipients(toBeDeletedChipText, expectedFinalChips[0].text, expectedFinalChips[1].text)
+ deleteChipAt(position = 0)
+ verify { hasRecipientChips(*expectedFinalChips) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190265")
+ fun testMultipleChipsMiddleChipDeletion() {
+ val toBeDeletedChipText = "additional@chip.com"
+ val expectedFinalChips = arrayOf(
+ RecipientChipEntry(
+ index = 0,
+ text = "one",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Invalid
+ ),
+ RecipientChipEntry(
+ index = 1,
+ text = "two@test.com",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Valid
+ )
+ )
+
+ composerRobot {
+ toRecipientSection {
+ typeMultipleRecipients(expectedFinalChips[0].text, toBeDeletedChipText, expectedFinalChips[1].text)
+ deleteChipAt(position = 1)
+
+ verify { hasRecipientChips(*expectedFinalChips) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190266")
+ fun testAllChipsDeletion() {
+ val rawRecipients = arrayOf("test1@example.com", "test2@example.com", "test3@example.com")
+
+ composerRobot {
+ toRecipientSection {
+ typeMultipleRecipients(*rawRecipients)
+
+ for (index in 1..rawRecipients.size) {
+ deleteChipAt(rawRecipients.size - index)
+ }
+
+ verify { isEmptyField() }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190269")
+ fun testValidChipDeletionWithBackspace() {
+ composerRobot {
+ toRecipientSection {
+ typeRecipient("rec@ipient.com")
+ triggerChipCreation(ChipsCreationTrigger.NewLine)
+ tapBackspace()
+
+ verify { isEmptyField() }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190270")
+ fun testInvalidChipDeletionWithBackspace() {
+ composerRobot {
+ toRecipientSection {
+ typeRecipient("example.com")
+ triggerChipCreation(ChipsCreationTrigger.NewLine)
+ tapBackspace()
+
+ verify { isEmptyField() }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190271")
+ fun testMultipleValidChipsDeletionWithBackspace() {
+ val rawRecipients = arrayOf("rec@ipient.com", "rec@ipient1.com", "rec@ipient2.com")
+
+ composerRobot {
+ toRecipientSection {
+ validateChipsCreationAndDeletionWithBackspace(rawRecipients)
+ }
+ }
+ }
+
+ @Test
+ @TestId("190272")
+ fun testMultipleInvalidChipsDeletionWithBackspace() {
+ val rawRecipients = arrayOf("recipient.com", "recipient1.com", "recipient2.com")
+
+ composerRobot {
+ toRecipientSection {
+ validateChipsCreationAndDeletionWithBackspace(rawRecipients)
+ }
+ }
+ }
+
+ @Test
+ @TestId("190273")
+ fun testChipDeletionAndRecreation() {
+ composerRobot {
+ toRecipientSection {
+ validateChipDeletionAndRecreation { deleteChipAt(position = 1) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190274")
+ fun testChipDeletionAndRecreationWithBackspace() {
+ composerRobot {
+ toRecipientSection {
+ validateChipDeletionAndRecreation { tapBackspace() }
+ }
+ }
+ }
+
+ private fun ComposerRecipientsSection.validateSimpleChipCreationAndDeletion(chipEntry: RecipientChipEntry) {
+ typeRecipient(chipEntry.text)
+ triggerChipCreation(ChipsCreationTrigger.NewLine)
+ verify { hasRecipientChips(chipEntry) }
+
+ deleteChipAt(chipEntry.index)
+ verify {
+ recipientChipIsNotDisplayed(chipEntry)
+ isFieldFocused()
+ }
+ }
+
+ private fun ComposerRecipientsSection.validateChipsCreationAndDeletionWithBackspace(rawRecipients: Array) {
+ typeMultipleRecipients(*rawRecipients)
+ repeat(times = rawRecipients.size) { tapBackspace() }
+
+ verify {
+ isFieldFocused()
+ isEmptyField()
+ }
+ }
+
+ private fun ComposerRecipientsSection.validateChipDeletionAndRecreation(
+ deleteAction: ComposerRecipientsSection.() -> Unit
+ ) {
+ val expectedChips = arrayOf(
+ RecipientChipEntry(
+ index = 0,
+ text = "rec@ipient.com",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Valid
+ ),
+ RecipientChipEntry(
+ index = 1,
+ text = "rec@ipient2.com",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Valid
+ )
+ )
+
+ typeMultipleRecipients("rec@ipient.com", "rec@ipient0.com")
+ deleteAction()
+ typeRecipient("rec@ipient2.com", autoConfirm = true)
+
+ verify { hasRecipientChips(*expectedChips) }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsCollapsedChipsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsCollapsedChipsTests.kt
new file mode 100644
index 0000000000..21d301fb66
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsCollapsedChipsTests.kt
@@ -0,0 +1,200 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.chips
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.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.section.recipients.bccRecipientSection
+import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection
+import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection
+import ch.protonmail.android.uitest.robot.composer.section.recipients.verify
+import ch.protonmail.android.uitest.robot.composer.section.subjectSection
+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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerRecipientsCollapsedChipsTests : MockedNetworkTest(), ComposerChipsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val expectedPlusOneEntry = RecipientChipEntry(
+ index = 1,
+ text = "+1",
+ hasDeleteIcon = false,
+ state = RecipientChipValidationState.Valid
+ )
+
+ private val expectedMultipleFocusedEntries = arrayOf(
+ RecipientChipEntry(
+ index = 0,
+ text = "test@example.com",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Valid
+ ),
+ RecipientChipEntry(
+ index = 1,
+ text = "test2@example.com",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Valid
+ ),
+ RecipientChipEntry(
+ index = 2,
+ text = "test3@example.com",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Valid
+ )
+ )
+
+ @Before
+ fun navigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("190243")
+ fun testMoveFocusFromToFieldCollapseChips() {
+ composerRobot {
+ toRecipientSection {
+ typeMultipleRecipients("recipient1@example.com", "recipient2@example.com")
+ }
+
+ subjectSection { focusField() }
+
+ toRecipientSection {
+ verify { hasRecipientChips(expectedPlusOneEntry) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190244")
+ fun testMoveFocusFromCcFieldCollapseChips() {
+ composerRobot {
+ toRecipientSection {
+ expandCcAndBccFields()
+ }
+
+ ccRecipientSection {
+ typeMultipleRecipients("recipient1@example.com", "recipient2@example.com")
+ }
+
+ subjectSection { focusField() }
+
+ ccRecipientSection {
+ verify { hasRecipientChips(expectedPlusOneEntry) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190245")
+ fun testMoveFocusFromBccFieldCollapseChips() {
+ composerRobot {
+ toRecipientSection {
+ expandCcAndBccFields()
+ }
+
+ bccRecipientSection {
+ typeMultipleRecipients("recipient1@example.com", "recipient2@example.com")
+ }
+
+ subjectSection { focusField() }
+
+ bccRecipientSection {
+ verify { hasRecipientChips(expectedPlusOneEntry) }
+ }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("190246")
+ fun testExpandedChipsRestoreRecipientsContents() {
+ val unfocusedEntries = arrayOf(
+ RecipientChipEntry(
+ index = 0,
+ text = "test@example.com",
+ hasDeleteIcon = false,
+ state = RecipientChipValidationState.Valid
+ ),
+ RecipientChipEntry(
+ index = 1,
+ text = "+2",
+ hasDeleteIcon = false,
+ state = RecipientChipValidationState.Valid
+ )
+ )
+
+ composerRobot {
+ toRecipientSection {
+ typeMultipleRecipients("test@example.com", "test2@example.com", "test3@example.com")
+
+ verify { hasRecipientChips(*expectedMultipleFocusedEntries) }
+ }
+
+ subjectSection { focusField() }
+
+ toRecipientSection {
+ verify { hasRecipientChips(*unfocusedEntries) }
+
+ focusField()
+
+ verify { hasRecipientChips(*expectedMultipleFocusedEntries) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190247")
+ fun testChipAdditionAfterFieldExpansion() {
+ composerRobot {
+ toRecipientSection {
+ typeMultipleRecipients("test@example.com", "test2@example.com")
+ }
+
+ subjectSection { focusField() }
+
+ toRecipientSection {
+ typeRecipient("test3@example.com", autoConfirm = true)
+
+ verify { hasRecipientChips(*expectedMultipleFocusedEntries) }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsDuplicatedChipsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsDuplicatedChipsTests.kt
new file mode 100644
index 0000000000..2d8e5ffe10
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsDuplicatedChipsTests.kt
@@ -0,0 +1,115 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.chips
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.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.section.recipients.bccRecipientSection
+import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection
+import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection
+import ch.protonmail.android.uitest.robot.composer.section.recipients.verify
+import ch.protonmail.android.uitest.robot.composer.section.subjectSection
+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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerRecipientsDuplicatedChipsTests : MockedNetworkTest(), ComposerChipsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val expectedRecipientChip = RecipientChipEntry(
+ index = 0,
+ text = "rec@ipient.com",
+ state = RecipientChipValidationState.Valid
+ )
+
+ @Before
+ fun navigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @Test
+ @TestId("190255")
+ fun testSameAddressInMultipleField() {
+ composerRobot {
+ toRecipientSection {
+ expandCcAndBccFields()
+ typeRecipient(expectedRecipientChip.text)
+ }
+
+ ccRecipientSection {
+ typeRecipient(expectedRecipientChip.text)
+ }
+
+ bccRecipientSection {
+ typeRecipient(expectedRecipientChip.text)
+ }
+
+ subjectSection { focusField() }
+
+ toRecipientSection {
+ verify { hasRecipientChips(expectedRecipientChip) }
+ }
+
+ ccRecipientSection {
+ verify { hasRecipientChips(expectedRecipientChip) }
+ }
+
+ bccRecipientSection {
+ verify { hasRecipientChips(expectedRecipientChip) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190256")
+ fun testSameAddressInSameFieldIsDiscarded() {
+ val expectedFocusedRecipientChip = expectedRecipientChip.copy(hasDeleteIcon = true)
+ val expectedNotExistsChip = expectedFocusedRecipientChip.copy(index = 1)
+
+ composerRobot {
+ toRecipientSection {
+ typeMultipleRecipients(expectedFocusedRecipientChip.text, expectedFocusedRecipientChip.text)
+
+ verify {
+ hasRecipientChips(expectedFocusedRecipientChip)
+ recipientChipIsNotDisplayed(expectedNotExistsChip)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsInvalidChipsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsInvalidChipsTests.kt
new file mode 100644
index 0000000000..72309502dd
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsInvalidChipsTests.kt
@@ -0,0 +1,152 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.chips
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.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.section.recipients.ComposerRecipientsSection
+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
+import ch.protonmail.android.uitest.robot.composer.section.recipients.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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerRecipientsInvalidChipsTests : MockedNetworkTest(), ComposerChipsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val expectedRecipientChip = RecipientChipEntry(
+ index = 0,
+ text = "test",
+ state = RecipientChipValidationState.Invalid
+ )
+
+ @Before
+ fun navigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("190232")
+ fun testInvalidToRecipientChip() {
+ composerRobot {
+ toRecipientSection {
+ createAndVerifyInvalidChip()
+ }
+ }
+ }
+
+ @Test
+ @TestId("190233")
+ fun testInvalidCcRecipientChip() {
+ composerRobot {
+ toRecipientSection {
+ expandCcAndBccFields()
+ }
+
+ ccRecipientSection {
+ createAndVerifyInvalidChip()
+ }
+ }
+ }
+
+ @Test
+ @TestId("190234")
+ fun testInvalidBccRecipientChip() {
+ composerRobot {
+ toRecipientSection {
+ expandCcAndBccFields()
+ }
+
+ bccRecipientSection {
+ createAndVerifyInvalidChip()
+ }
+ }
+ }
+
+ @Test
+ @TestId("190235")
+ fun testInvalidRecipientChipOnFocusChange() {
+ composerRobot {
+ toRecipientSection {
+ expandCcAndBccFields()
+ typeRecipient("test")
+ }
+
+ ccRecipientSection {
+ tapRecipientField()
+ }
+
+ toRecipientSection {
+ verify { hasRecipientChips(expectedRecipientChip) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190238")
+ fun testInvalidRecipientChipOnOnNewLine() {
+ composerRobot {
+ toRecipientSection {
+ createAndVerifyInvalidChip(trigger = ChipsCreationTrigger.NewLine)
+ }
+ }
+ }
+
+ @Test
+ @TestId("190239")
+ fun testMultipleInvalidRecipientChipsOnNewLine() {
+ composerRobot {
+ toRecipientSection {
+ withMultipleRecipients(size = 100, state = RecipientChipValidationState.Invalid) {
+ typeRecipient(it.text, autoConfirm = true)
+
+ verify { hasRecipientChips(it) }
+ }
+ }
+ }
+ }
+
+ private fun ComposerRecipientsSection.createAndVerifyInvalidChip(
+ trigger: ChipsCreationTrigger = ChipsCreationTrigger.ImeAction
+ ) = createAndVerifyChip(state = RecipientChipValidationState.Invalid, trigger)
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsValidChipsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsValidChipsTests.kt
new file mode 100644
index 0000000000..c3cec91cea
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsValidChipsTests.kt
@@ -0,0 +1,152 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.chips
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.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.section.recipients.ComposerRecipientsSection
+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
+import ch.protonmail.android.uitest.robot.composer.section.recipients.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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerRecipientsValidChipsTests : MockedNetworkTest(), ComposerChipsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val expectedRecipientChipEntry = RecipientChipEntry(
+ index = 0,
+ text = "rec@ipient.com",
+ state = RecipientChipValidationState.Valid
+ )
+
+ @Before
+ fun navigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("190228")
+ fun testValidToRecipientChip() {
+ composerRobot {
+ toRecipientSection {
+ createAndVerifyValidChip()
+ }
+ }
+ }
+
+ @Test
+ @TestId("190229")
+ fun testValidCcRecipientChip() {
+ composerRobot {
+ toRecipientSection {
+ expandCcAndBccFields()
+ }
+
+ ccRecipientSection {
+ createAndVerifyValidChip()
+ }
+ }
+ }
+
+ @Test
+ @TestId("190230")
+ fun testValidBccRecipientChip() {
+ composerRobot {
+ toRecipientSection {
+ expandCcAndBccFields()
+ }
+
+ bccRecipientSection {
+ createAndVerifyValidChip()
+ }
+ }
+ }
+
+ @Test
+ @TestId("190231")
+ fun testValidRecipientChipOnFocusChange() {
+ composerRobot {
+ toRecipientSection {
+ expandCcAndBccFields()
+ typeRecipient("rec@ipient.com")
+ }
+
+ ccRecipientSection {
+ tapRecipientField()
+ }
+
+ toRecipientSection {
+ verify { hasRecipientChips(expectedRecipientChipEntry) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("190236")
+ fun testValidRecipientChipOnNewLine() {
+ composerRobot {
+ toRecipientSection {
+ createAndVerifyValidChip(trigger = ChipsCreationTrigger.NewLine)
+ }
+ }
+ }
+
+ @Test
+ @TestId("190237")
+ fun testMultipleValidRecipientChipsOnNewLine() {
+ composerRobot {
+ toRecipientSection {
+ withMultipleRecipients(size = 100, state = RecipientChipValidationState.Valid) {
+ typeRecipient(it.text, autoConfirm = true)
+
+ verify { hasRecipientChips(it) }
+ }
+ }
+ }
+ }
+
+ private fun ComposerRecipientsSection.createAndVerifyValidChip(
+ trigger: ChipsCreationTrigger = ChipsCreationTrigger.ImeAction
+ ) = createAndVerifyChip(state = RecipientChipValidationState.Valid, trigger)
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsInvalidRecipientsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsInvalidRecipientsTests.kt
new file mode 100644
index 0000000000..9886d6ad8e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsInvalidRecipientsTests.kt
@@ -0,0 +1,125 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.drafts
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.composer.composerRobot
+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
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerDraftsInvalidRecipientsTests : MockedNetworkTest(), ComposerDraftsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val invalidEmailAddress = "test@aa"
+
+ @Before
+ fun navigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @Test
+ @TestId("207358")
+ fun testIncompleteAddressDoesNotTriggerDraftCreation() {
+ composerRobot {
+ toRecipientSection { typeRecipient(invalidEmailAddress) }
+
+ topAppBarSection { tapCloseButton() }
+ }
+
+ verifyEmptyDrafts()
+ }
+
+ @Test
+ @TestId("207359")
+ fun testInvalidToAddressDoesNotTriggerDraftCreation() {
+ composerRobot {
+ toRecipientSection { typeRecipient(invalidEmailAddress, autoConfirm = true) }
+
+ topAppBarSection { tapCloseButton() }
+ }
+
+ verifyEmptyDrafts()
+ }
+
+ @Test
+ @TestId("207360")
+ fun testInvalidCcAddressDoesNotTriggerDraftCreation() {
+ composerRobot {
+ toRecipientSection { expandCcAndBccFields() }
+ ccRecipientSection { typeRecipient(invalidEmailAddress, autoConfirm = true) }
+
+ topAppBarSection { tapCloseButton() }
+ }
+
+ verifyEmptyDrafts()
+ }
+
+ @Test
+ @TestId("207361")
+ fun testInvalidBccAddressDoesNotTriggerDraftCreation() {
+ composerRobot {
+ toRecipientSection { expandCcAndBccFields() }
+ bccRecipientSection { typeRecipient(invalidEmailAddress, autoConfirm = true) }
+
+ topAppBarSection { tapCloseButton() }
+ }
+
+ verifyEmptyDrafts()
+ }
+
+ @Test
+ @TestId("207362")
+ fun testInvalidAddressesDoNotTriggerDraftCreation() {
+ composerRobot {
+ toRecipientSection {
+ typeRecipient(invalidEmailAddress, autoConfirm = true)
+ expandCcAndBccFields()
+ }
+
+ ccRecipientSection { typeRecipient(invalidEmailAddress, autoConfirm = true) }
+ bccRecipientSection { typeRecipient(invalidEmailAddress, autoConfirm = true) }
+
+ topAppBarSection { tapCloseButton() }
+ }
+
+ verifyEmptyDrafts()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsMainTests.kt
new file mode 100644
index 0000000000..4b8f440a35
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsMainTests.kt
@@ -0,0 +1,98 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.drafts
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.models.mailbox.ParticipantEntry
+import ch.protonmail.android.uitest.robot.composer.composerRobot
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+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 org.junit.Before
+import org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerDraftsMainTests : MockedNetworkTest(), ComposerDraftsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val subject = "A subject!"
+ private val messageBody = "sample body"
+
+ @Before
+ fun navigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @Test
+ @TestId("190295", "207357")
+ fun testNoDraftSavedUponComposerExit() {
+ composerRobot { topAppBarSection { tapCloseButton() } }
+ verifyEmptyDrafts()
+ }
+
+ @Test
+ @TestId("190296", "207367")
+ fun testDraftSavedWithSubjectOnlyUponEmptyBody() {
+ composerRobot {
+ prepareDraft(toRecipients = emptyList(), subject = subject, body = null)
+ topAppBarSection { tapCloseButton() }
+ }
+
+ verifyDraftCreation(ParticipantEntry.NoRecipient, subject = subject)
+ }
+
+ @Test
+ @TestId("190297")
+ fun testDraftSavedWhenBodyIsPopulated() {
+ composerRobot {
+ prepareDraft(toRecipients = emptyList(), body = "sample body")
+ topAppBarSection { tapCloseButton() }
+ }
+
+ verifyDraftCreation(ParticipantEntry.NoRecipient, body = messageBody)
+ }
+
+ @Test
+ @TestId("190298")
+ fun testDraftSavedWhenAllFieldsArePopulated() {
+ val participant = "test@example.com"
+
+ composerRobot {
+ prepareDraft(toRecipient = participant, subject = subject, body = messageBody)
+ topAppBarSection { tapCloseButton() }
+ }
+
+ verifyDraftCreation(participant, subject = subject, body = messageBody)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsSendButtonTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsSendButtonTests.kt
new file mode 100644
index 0000000000..afa15459d2
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsSendButtonTests.kt
@@ -0,0 +1,114 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.drafts
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.common.section.fullscreenLoaderSection
+import ch.protonmail.android.uitest.robot.composer.composerRobot
+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.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerDraftsSendButtonTests : MockedNetworkTest(
+ loginType = LoginTestUserTypes.Paid.FancyCapybara
+), ComposerDraftsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("222787")
+ fun checkComposerSendButtonEnabledUponOpeningAValidDraft() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher {
+ addMockRequests(
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=8&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_222787.json"
+ withStatusCode 200,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_222787.json"
+ withStatusCode 200 matchWildcards true
+ )
+ }
+
+ navigator { navigateTo(Destination.Drafts) }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ composerRobot {
+ fullscreenLoaderSection { waitUntilGone() }
+
+ topAppBarSection { verify { isSendButtonEnabled() } }
+ }
+ }
+
+ @Test
+ @TestId("222787/2", "222788")
+ fun checkComposerSendButtonDisabledUponRemovingRecipientFromValidDraft() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher {
+ addMockRequests(
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=8&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_222787.json"
+ withStatusCode 200,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_222787.json"
+ withStatusCode 200 matchWildcards true
+ )
+ }
+
+ navigator { navigateTo(Destination.Drafts) }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ composerRobot {
+ fullscreenLoaderSection { waitUntilGone() }
+
+ toRecipientSection { deleteChipAt(position = 0) }
+
+ topAppBarSection { verify { isSendButtonDisabled() } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsTests.kt
new file mode 100644
index 0000000000..211632b212
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsTests.kt
@@ -0,0 +1,97 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.drafts
+
+import ch.protonmail.android.uitest.e2e.composer.ComposerTests
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.MailboxType
+import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry
+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.model.snackbar.ComposerSnackbar
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.emptyListSection
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.mailbox.section.verify
+import ch.protonmail.android.uitest.robot.menu.menuRobot
+
+internal interface ComposerDraftsTests : ComposerTests {
+
+ fun verifyEmptyDrafts() {
+ menuRobot {
+ openSidebarMenu()
+ openDrafts()
+ }
+
+ mailboxRobot {
+ topAppBarSection { verify { isMailbox(MailboxType.Drafts) } }
+ emptyListSection { verify { isShown() } }
+ }
+ }
+
+ fun verifyDraftCreation(vararg recipients: String, subject: String = "", body: String = "") {
+ val participants = recipients.map { ParticipantEntry.WithParticipant(it) }
+ verifyDraftCreation(expectedRecipients = participants, subject = subject, body = body)
+ }
+
+ fun verifyDraftCreation(vararg expectedRecipient: ParticipantEntry, subject: String = "", body: String = "") {
+ verifyDraftCreation(expectedRecipient.toList(), subject = subject, body = body)
+ }
+
+ fun verifyDraftCreation(expectedRecipient: String, subject: String = "", body: String = "") {
+ verifyDraftCreation(
+ expectedRecipients = listOf(
+ ParticipantEntry.WithParticipant(expectedRecipient)
+ ),
+ subject = subject,
+ body = body
+ )
+ }
+
+ fun verifyDraftCreation(
+ expectedRecipients: List,
+ subject: String = "",
+ body: String = ""
+ ) {
+ val expectedDraftItem = MailboxListItemEntry(
+ index = 0,
+ avatarInitial = AvatarInitial.Draft,
+ participants = expectedRecipients,
+ date = "Jul 1, 2023",
+ subject = subject
+ )
+
+ mailboxRobot {
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.DraftSaved) } }
+ }
+
+ menuRobot {
+ openSidebarMenu()
+ openDrafts()
+ }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(expectedDraftItem) }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsValidRecipientsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsValidRecipientsTests.kt
new file mode 100644
index 0000000000..ab885172ff
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsValidRecipientsTests.kt
@@ -0,0 +1,107 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.drafts
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.robot.composer.composerRobot
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerDraftsValidRecipientsTests : MockedNetworkTest(), ComposerDraftsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val validToRecipient = "a@b.c"
+ private val validCcRecipient = "d@e.f"
+ private val validBccRecipient = "g@h.i"
+
+ @Before
+ fun navigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("207363")
+ fun testValidToAddressDoesTriggerDraftCreation() {
+ composerRobot {
+ prepareDraft(toRecipients = listOf(validToRecipient))
+ topAppBarSection { tapCloseButton() }
+ }
+
+ verifyDraftCreation(validToRecipient)
+ }
+
+ @Test
+ @TestId("207364")
+ fun testValidCcAddressDoesTriggerDraftCreation() {
+ composerRobot {
+ prepareDraft(ccRecipients = listOf(validCcRecipient))
+ topAppBarSection { tapCloseButton() }
+ }
+
+ verifyDraftCreation(validCcRecipient)
+ }
+
+ @Test
+ @TestId("207365")
+ fun testValidBccAddressDoesTriggerDraftCreation() {
+ composerRobot {
+ prepareDraft(bccRecipients = listOf(validBccRecipient))
+ topAppBarSection { tapCloseButton() }
+ }
+
+ verifyDraftCreation(validBccRecipient)
+ }
+
+ @Test
+ @TestId("207366")
+ fun testValidAddressesDoTriggerDraftCreation() {
+ composerRobot {
+ prepareDraft(
+ toRecipients = listOf(validToRecipient),
+ ccRecipients = listOf(validCcRecipient),
+ bccRecipients = listOf(validBccRecipient)
+ )
+
+ topAppBarSection { tapCloseButton() }
+ }
+
+ verifyDraftCreation(validToRecipient, validCcRecipient, validBccRecipient, subject = "", body = "")
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderExternalUserTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderExternalUserTests.kt
new file mode 100644
index 0000000000..c14181b219
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderExternalUserTests.kt
@@ -0,0 +1,80 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.sender
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.MockedNetworkTest
+import ch.protonmail.android.uitest.e2e.composer.ComposerTests
+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.robot.composer.composerRobot
+import ch.protonmail.android.uitest.robot.composer.model.sender.ChangeSenderEntry
+import ch.protonmail.android.uitest.robot.composer.section.changeSenderBottomSheet
+import ch.protonmail.android.uitest.robot.composer.section.senderSection
+import ch.protonmail.android.uitest.robot.composer.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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerSenderExternalUserTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.External.StrangeWalrus),
+ ComposerTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val expectedPrimaryAddress = "strangewalrus@proton.black"
+
+ @Before
+ fun navigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @Test
+ @TestId("192120")
+ fun testExternalAddressCannotBeSetAsSender() {
+ val expectedEntries = arrayOf(
+ ChangeSenderEntry(index = 0, address = expectedPrimaryAddress),
+ ChangeSenderEntry(index = 1, address = "strangewalrus@pm.me.proton.black"),
+ ChangeSenderEntry(index = 2, address = "strangewalrus@example.com", isEnabled = false)
+ )
+
+ composerRobot {
+ senderSection { verify { hasValue(expectedPrimaryAddress) } }
+
+ senderSection { tapChangeSender() }
+ changeSenderBottomSheet {
+ verify { hasEntries(*expectedEntries) }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderFreeUserTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderFreeUserTests.kt
new file mode 100644
index 0000000000..bb974bb7a7
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderFreeUserTests.kt
@@ -0,0 +1,81 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.sender
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.e2e.composer.ComposerTests
+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.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.snackbar.ComposerSnackbar
+import ch.protonmail.android.uitest.robot.composer.section.senderSection
+import ch.protonmail.android.uitest.robot.composer.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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerSenderFreeUserTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.Free.SleepyKoala),
+ ComposerTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Before
+ fun navigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @Test
+ @TestId("192116")
+ fun testMainSenderForFreeUser() {
+ val expectedAddress = "sleepykoala@proton.black"
+
+ composerRobot {
+ senderSection { verify { hasValue(expectedAddress) } }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("192118")
+ fun testFreeUserCannotChangeSender() {
+ composerRobot {
+ senderSection { tapChangeSender() }
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.UpgradePlanToChangeSender) } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderPaidUserTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderPaidUserTests.kt
new file mode 100644
index 0000000000..a7af033f6a
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderPaidUserTests.kt
@@ -0,0 +1,182 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.sender
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority
+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.e2e.composer.ComposerTests
+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.robot.common.section.keyboardSection
+import ch.protonmail.android.uitest.robot.composer.composerRobot
+import ch.protonmail.android.uitest.robot.composer.model.sender.ChangeSenderEntry
+import ch.protonmail.android.uitest.robot.composer.section.changeSenderBottomSheet
+import ch.protonmail.android.uitest.robot.composer.section.messageBodySection
+import ch.protonmail.android.uitest.robot.composer.section.senderSection
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.composer.section.verify
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.topAppBarSection
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerSenderPaidUserTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara),
+ ComposerTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val expectedPrimaryAddress = "fancycapybara@proton.black"
+
+ @Test
+ @TestId("192115")
+ fun testMainSenderForPaidUser() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ senderSection { verify { hasValue(expectedPrimaryAddress) } }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("192117", "192119", "192123")
+ fun testMultipleAliasForPaidUser() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher {
+ addMockRequests(
+ get("/core/v4/addresses")
+ respondWith "/core/v4/addresses/addresses_192117.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ val expectedEntries = arrayOf(
+ ChangeSenderEntry(index = 0, address = "fancycapybara@proton.black"),
+ ChangeSenderEntry(index = 1, address = "shortcapybara@pm.me.proton.black"),
+ ChangeSenderEntry(index = 2, address = "fancycapybara@pm.me.proton.black"),
+ ChangeSenderEntry(index = 3, address = "fancynotenabled@proton.black", isEnabled = false)
+ )
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ senderSection { verify { hasValue(expectedPrimaryAddress) } }
+
+ senderSection { tapChangeSender() }
+ changeSenderBottomSheet {
+ verify { hasEntries(*expectedEntries) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("192122")
+ fun testPrimaryAddressIsStillDefaultAfterDraftIsSaved() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher {
+ addMockRequests(
+ get("/core/v4/addresses")
+ respondWith "/core/v4/addresses/addresses_192122.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ val expectedSecondaryAddress = "shortcapybara@pm.me.proton.black"
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ senderSection { verify { hasValue(expectedPrimaryAddress) } }
+
+ senderSection { tapChangeSender() }
+ changeSenderBottomSheet { tapEntryAt(position = 1) }
+ senderSection { verify { hasValue(expectedSecondaryAddress) } }
+
+ messageBodySection { typeMessageBody("Test value") }
+ keyboardSection { dismissKeyboard() }
+ topAppBarSection { tapCloseButton() }
+ }
+
+ mailboxRobot {
+ topAppBarSection { tapComposerIcon() }
+ }
+
+ composerRobot {
+ senderSection { verify { hasValue(expectedPrimaryAddress) } }
+ }
+ }
+
+ @Test
+ @TestId("192125")
+ fun testSenderBottomSheetSwipeDismissal() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ keyboardSection { dismissKeyboard() }
+ senderSection { tapChangeSender() }
+
+ changeSenderBottomSheet {
+ dismiss()
+ verify { isHidden() }
+ }
+
+ senderSection { verify { hasValue(expectedPrimaryAddress) } }
+ }
+ }
+
+ @Test
+ @TestId("192126")
+ fun testSenderBottomSheetTapDismissal() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ keyboardSection { dismissKeyboard() }
+ senderSection { tapChangeSender() }
+ changeSenderBottomSheet { verify { isShown() } }
+
+ senderSection { tapChangeSender() }
+ changeSenderBottomSheet { verify { isHidden() } }
+ senderSection { verify { hasValue(expectedPrimaryAddress) } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendButtonTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendButtonTests.kt
new file mode 100644
index 0000000000..6017ab548e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendButtonTests.kt
@@ -0,0 +1,204 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.sending
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.e2e.composer.ComposerTests
+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.composer.composerRobot
+import ch.protonmail.android.uitest.robot.composer.section.composerAlertDialogSection
+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
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.composer.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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerSendButtonTests : MockedNetworkTest(), ComposerTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val placeholderString = "Random text"
+
+ @Before
+ fun setMockDispatcher() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("216681")
+ fun checkComposerSendButtonDisabledUponOpening() {
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ topAppBarSection { verify { isSendButtonDisabled() } }
+ }
+ }
+
+ @Test
+ @TestId("216682")
+ fun checkComposerSendButtonDisabledUponOnlySubjectAdded() {
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ subjectSection { typeSubject(placeholderString) }
+
+ topAppBarSection { verify { isSendButtonDisabled() } }
+ }
+ }
+
+ @Test
+ @TestId("216683")
+ fun checkComposerSendButtonDisabledUponOnlyMessageBodyAdded() {
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ messageBodySection { typeMessageBody(placeholderString) }
+
+ topAppBarSection { verify { isSendButtonDisabled() } }
+ }
+ }
+
+ @Test
+ @TestId("216684")
+ fun checkComposerSendButtonDisabledUponSubjectAndMessageBodyAdded() {
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ subjectSection { typeSubject(placeholderString) }
+ messageBodySection { typeMessageBody(placeholderString) }
+
+ topAppBarSection { verify { isSendButtonDisabled() } }
+ }
+ }
+
+ @Test
+ @TestId("216685")
+ fun checkComposerSendButtonDisabledUponPressingBackspaceInRecipientFields() {
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ toRecipientSection { tapBackspace() }
+ topAppBarSection { verify { isSendButtonDisabled() } }
+ }
+ }
+
+ @Test
+ @TestId("216686")
+ fun checkComposerSendButtonDisabledUponAddingAndRemovingRecipients() {
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ toRecipientSection { typeRecipient("someone@proton.me", autoConfirm = true) }
+ topAppBarSection { verify { isSendButtonEnabled() } }
+
+ toRecipientSection { deleteChipAt(0) }
+ topAppBarSection { verify { isSendButtonDisabled() } }
+ }
+ }
+
+ @Test
+ @TestId("216687")
+ fun checkComposerSendButtonDisabledUponAddingAndDeletingInvalidRecipient() {
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ toRecipientSection {
+ typeRecipient("proton.me", autoConfirm = true)
+ deleteChipAt(0)
+ }
+
+ topAppBarSection { verify { isSendButtonDisabled() } }
+ }
+ }
+
+ @Test
+ @TestId("216688")
+ fun checkComposerSendButtonDisabledUponAddingAnInvalidRecipient() {
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ toRecipientSection { typeRecipient("proton.me", autoConfirm = true) }
+
+ topAppBarSection { verify { isSendButtonDisabled() } }
+ }
+ }
+
+ @Test
+ @TestId("216689")
+ fun checkComposerSendButtonDisabledUponAddingMultipleRecipientsWithOneInvalid() {
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ toRecipientSection { typeMultipleRecipients("proton.me", "test@proton.me") }
+
+ topAppBarSection { verify { isSendButtonDisabled() } }
+ }
+ }
+
+ @Test
+ @TestId("268527")
+ fun checkConfirmationDialogIsShownWhenSubjectIsEmptyAndSendButtonClicked() {
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ toRecipientSection { typeRecipient("test@proton.me") }
+ messageBodySection { typeMessageBody(placeholderString) }
+
+ topAppBarSection { verify { isSendButtonEnabled() } }
+ topAppBarSection { tapSendButton() }
+
+ composerAlertDialogSection {
+ verify {
+ isSendWithEmptySubjectDialogDisplayed()
+ }
+
+ clickSendWithEmptySubjectDialogDismissButton()
+
+ verify {
+ isSendWithEmptySubjectDialogDismissed()
+ }
+ }
+
+ subjectSection {
+ verify { hasEmptySubject() }
+
+ verify { hasFocus() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToExternalUserTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToExternalUserTests.kt
new file mode 100644
index 0000000000..e3b735b752
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToExternalUserTests.kt
@@ -0,0 +1,156 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.sending
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.e2e.composer.ComposerTests
+import ch.protonmail.android.uitest.helpers.core.TestId
+import ch.protonmail.android.uitest.helpers.core.TestingNotes
+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.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.snackbar.ComposerSnackbar
+import ch.protonmail.android.uitest.robot.composer.section.composerAlertDialogSection
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.util.StringUtils
+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 org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@TestingNotes("Scope to be expanded once MAILANDR-988 is addressed.")
+@UninstallModules(ServerProofModule::class)
+internal class ComposerSendMessageToExternalUserTests : MockedNetworkTest(
+ loginType = LoginTestUserTypes.Paid.FancyCapybara
+), ComposerTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val externalUser = "test@example.com"
+ private val subject = "A subject"
+ private val baseMessageBody = "A message body"
+
+ @Before
+ fun setupAndNavigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = true,
+ useDefaultSendMessageResponse = true
+ )
+
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @After
+ fun verifyMessageSent() {
+ mailboxRobot {
+ snackbarSection {
+ verify {
+ isDisplaying(ComposerSnackbar.SendingMessage)
+ isDisplaying(ComposerSnackbar.MessageSent)
+ }
+ }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("216690")
+ fun testMessageSendingToExternalUser() {
+ composerRobot {
+ prepareDraft(externalUser, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+
+ @Test
+ @TestId("216728")
+ fun testMessageSendingToExternalUseWithNoBody() {
+ composerRobot {
+ prepareDraft(externalUser, subject = subject)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216732")
+ fun testMessageSendingToExternalUseWithNoBodyOrSubject() {
+ composerRobot {
+ prepareDraft(externalUser)
+ topAppBarSection { tapSendButton() }
+ composerAlertDialogSection { clickSendWithEmptySubjectDialogConfirmButton() }
+ }
+ }
+
+ @Test
+ @TestId("216692")
+ fun testMessageSendingCcExternalUser() {
+ composerRobot {
+ prepareDraft(ccRecipient = externalUser, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216693")
+ fun testMessageSendingBccExternalUser() {
+ composerRobot {
+ prepareDraft(bccRecipient = externalUser, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("219564")
+ fun testMessageSendingLongBodyToExternalUser() {
+ val body = StringUtils.generateRandomString(length = 15000)
+
+ composerRobot {
+ prepareDraft(bccRecipient = externalUser, subject = subject, body = body)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("219639")
+ fun testMessageSendingWithEmojisAsSubjectToExternalUser() {
+ val emojiSubject = "😖😫😩🥺😢😭😮💨😤😠😡"
+
+ composerRobot {
+ prepareDraft(bccRecipient = externalUser, subject = emojiSubject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToMultipleExternalTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToMultipleExternalTests.kt
new file mode 100644
index 0000000000..d6ae4f14f9
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToMultipleExternalTests.kt
@@ -0,0 +1,183 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.sending
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.e2e.composer.ComposerTests
+import ch.protonmail.android.uitest.helpers.core.TestId
+import ch.protonmail.android.uitest.helpers.core.TestingNotes
+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.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.snackbar.ComposerSnackbar
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+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 org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@TestingNotes("Scope to be expanded once MAILANDR-988 is addressed.")
+@UninstallModules(ServerProofModule::class)
+internal class ComposerSendMessageToMultipleExternalTests : MockedNetworkTest(
+ loginType = LoginTestUserTypes.Paid.FancyCapybara
+), ComposerTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val externalRecipientTo = "test@example.com"
+ private val externalRecipientCc = "test2@example.com"
+ private val externalRecipientBcc = "test3@example.com"
+ private val mergedRecipients = listOf(externalRecipientTo, externalRecipientCc, externalRecipientBcc)
+ private val subject = "A subject"
+ private val baseMessageBody = "A message body"
+
+ @Before
+ fun setupAndNavigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = true,
+ useDefaultSendMessageResponse = true
+ )
+
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @After
+ fun verifyMessageSent() {
+ mailboxRobot {
+ snackbarSection {
+ verify {
+ isDisplaying(ComposerSnackbar.SendingMessage)
+ isDisplaying(ComposerSnackbar.MessageSent)
+ }
+ }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("216696")
+ fun testMessageSendingToMultipleExternalUsers() {
+ composerRobot {
+ prepareDraft(mergedRecipients, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216729")
+ fun testMessageSendingToMultipleExternalUsersWithNoBody() {
+ composerRobot {
+ prepareDraft(mergedRecipients, subject = subject)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216697")
+ fun testMessageSendingCcMultipleExternalUsers() {
+ composerRobot {
+ prepareDraft(ccRecipients = mergedRecipients, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216698")
+ fun testMessageSendingBccMultipleExternalUsers() {
+ composerRobot {
+ prepareDraft(bccRecipients = mergedRecipients, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216702")
+ fun testMessageSendingToAndCcExternalUsers() {
+ composerRobot {
+ prepareDraft(
+ externalRecipientTo,
+ ccRecipient = externalRecipientCc,
+ subject = subject,
+ body = baseMessageBody
+ )
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216705")
+ fun testMessageSendingToAndBccExternalUsers() {
+ composerRobot {
+ prepareDraft(
+ externalRecipientTo,
+ bccRecipient = externalRecipientBcc,
+ subject = subject,
+ body = baseMessageBody
+ )
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216707")
+ fun testMessageSendingToAndCcAndBccExternalUsers() {
+ composerRobot {
+ prepareDraft(externalRecipientTo, externalRecipientCc, externalRecipientBcc, subject, baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216730")
+ fun testMessageSendingToAndCcAndBccExternalUsersWithNoBody() {
+ composerRobot {
+ prepareDraft(externalRecipientTo, externalRecipientCc, externalRecipientBcc, subject)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216714")
+ fun testMessageSendingToAndCcAndBccMultipleExternalUsers() {
+ val toRecipients = listOf(externalRecipientTo, "test4@example.com")
+ val ccRecipients = listOf(externalRecipientCc, "test5@example.com")
+ val bccRecipients = listOf(externalRecipientBcc, "test6@example.com")
+
+ composerRobot {
+ prepareDraft(toRecipients, ccRecipients, bccRecipients, subject, baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToMultipleProtonTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToMultipleProtonTests.kt
new file mode 100644
index 0000000000..638d85c7d5
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToMultipleProtonTests.kt
@@ -0,0 +1,189 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.sending
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.e2e.composer.ComposerTests
+import ch.protonmail.android.uitest.helpers.core.TestId
+import ch.protonmail.android.uitest.helpers.core.TestingNotes
+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.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.snackbar.ComposerSnackbar
+import ch.protonmail.android.uitest.robot.composer.section.composerAlertDialogSection
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+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 org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@TestingNotes("Scope to be expanded once MAILANDR-988 is addressed.")
+@UninstallModules(ServerProofModule::class)
+internal class ComposerSendMessageToMultipleProtonTests : MockedNetworkTest(
+ loginType = LoginTestUserTypes.Paid.FancyCapybara
+), ComposerTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val protonRecipientTo = "royalcat@proton.black"
+ private val protonRecipientCc = "royaldog@proton.black"
+ private val protonRecipientBcc = "specialfox@proton.black"
+ private val mergedRecipients = listOf(protonRecipientTo, protonRecipientCc, protonRecipientBcc)
+ private val subject = "A subject"
+ private val baseMessageBody = "A message body"
+
+ @Before
+ fun setupAndNavigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = true,
+ useDefaultSendMessageResponse = true
+ )
+
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @After
+ fun verifyMessageSent() {
+ mailboxRobot {
+ snackbarSection {
+ verify {
+ isDisplaying(ComposerSnackbar.SendingMessage)
+ isDisplaying(ComposerSnackbar.MessageSent)
+ }
+ }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("216699")
+ fun testMessageSendingToMultipleProtonUsers() {
+ composerRobot {
+ prepareDraft(mergedRecipients, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216636")
+ fun testMessageSendingToMultipleProtonUsersWithNoBody() {
+ composerRobot {
+ prepareDraft(mergedRecipients, subject = subject)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216723")
+ fun testMessageSendingToMultipleProtonUsersWithNoSubjectOrBody() {
+ composerRobot {
+ prepareDraft(toRecipients = mergedRecipients)
+ topAppBarSection { tapSendButton() }
+ composerAlertDialogSection { clickSendWithEmptySubjectDialogConfirmButton() }
+ }
+ }
+
+ @Test
+ @TestId("216700")
+ fun testMessageSendingCcMultipleProtonUsers() {
+ composerRobot {
+ prepareDraft(ccRecipients = mergedRecipients, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216701")
+ fun testMessageSendingBccMultipleProtonUsers() {
+ composerRobot {
+ prepareDraft(bccRecipients = mergedRecipients, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216703")
+ fun testMessageSendingToAndCcProtonUsers() {
+ composerRobot {
+ prepareDraft(protonRecipientTo, ccRecipient = protonRecipientCc, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216704")
+ fun testMessageSendingToAndBccProtonUsers() {
+ composerRobot {
+ prepareDraft(
+ protonRecipientTo,
+ bccRecipient = protonRecipientBcc,
+ subject = subject,
+ body = baseMessageBody
+ )
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216706")
+ fun testMessageSendingToAndCcAndBccProtonUsers() {
+ composerRobot {
+ prepareDraft(protonRecipientTo, protonRecipientCc, protonRecipientBcc, subject, baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216724")
+ fun testMessageSendingToAndCcAndBccProtonUsersWithNoBody() {
+ composerRobot {
+ prepareDraft(protonRecipientTo, protonRecipientCc, protonRecipientBcc, subject)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216714")
+ fun testMessageSendingToAndCcAndBccMultipleProtonUsers() {
+ val toRecipients = listOf(protonRecipientTo, "sleepykoala@proton.black")
+ val ccRecipients = listOf(protonRecipientCc, "happyllama@proton.black")
+ val bccRecipients = listOf(protonRecipientBcc, "strangewalrus@proton.black")
+
+ composerRobot {
+ prepareDraft(toRecipients, ccRecipients, bccRecipients, subject, baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToProtonTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToProtonTests.kt
new file mode 100644
index 0000000000..60593cc1ec
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToProtonTests.kt
@@ -0,0 +1,155 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.sending
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.e2e.composer.ComposerTests
+import ch.protonmail.android.uitest.helpers.core.TestId
+import ch.protonmail.android.uitest.helpers.core.TestingNotes
+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.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.snackbar.ComposerSnackbar
+import ch.protonmail.android.uitest.robot.composer.section.composerAlertDialogSection
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.util.StringUtils
+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 org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@TestingNotes("Scope to be expanded once MAILANDR-988 is addressed.")
+@UninstallModules(ServerProofModule::class)
+internal class ComposerSendMessageToProtonTests : MockedNetworkTest(
+ loginType = LoginTestUserTypes.Paid.FancyCapybara
+), ComposerTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val protonRecipient = "royalcat@proton.black"
+ private val subject = "A subject"
+ private val baseMessageBody = "A message body"
+
+ @Before
+ fun setupAndNavigateToComposer() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = true,
+ useDefaultSendMessageResponse = true
+ )
+
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @After
+ fun verifyMessageSent() {
+ mailboxRobot {
+ snackbarSection {
+ verify {
+ isDisplaying(ComposerSnackbar.SendingMessage)
+ isDisplaying(ComposerSnackbar.MessageSent)
+ }
+ }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("216691", "219591")
+ fun testMessageSendingToProtonUser() {
+ composerRobot {
+ prepareDraft(protonRecipient, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216691/2", "216722")
+ fun testMessageSendingToProtonUserWithNoBody() {
+ composerRobot {
+ prepareDraft(protonRecipient, subject = subject)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216691/3", "219632")
+ fun testMessageSendingToProtonUserWithNoBodyOrSubject() {
+ composerRobot {
+ prepareDraft(protonRecipient)
+ topAppBarSection { tapSendButton() }
+ composerAlertDialogSection { clickSendWithEmptySubjectDialogConfirmButton() }
+ }
+ }
+
+ @Test
+ @TestId("216694")
+ fun testMessageSendingCcProtonUser() {
+ composerRobot {
+ prepareDraft(ccRecipient = protonRecipient, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216695")
+ fun testMessageSendingBccProtonUser() {
+ composerRobot {
+ prepareDraft(bccRecipient = protonRecipient, subject = subject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("216720")
+ fun testMessageSendingLongBodyToProtonUser() {
+ val body = StringUtils.generateRandomString(length = 15000)
+
+ composerRobot {
+ prepareDraft(bccRecipient = protonRecipient, subject = subject, body = body)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+
+ @Test
+ @TestId("219635")
+ fun testMessageSendingWithEmojisAsSubjectToProtonUser() {
+ val emojiSubject = "😖😫😩🥺😢😭😮💨😤😠😡"
+
+ composerRobot {
+ prepareDraft(bccRecipient = protonRecipient, subject = emojiSubject, body = baseMessageBody)
+ topAppBarSection { tapSendButton() }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/errors/ComposerSendMessageNetworkErrors.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/errors/ComposerSendMessageNetworkErrors.kt
new file mode 100644
index 0000000000..a99d244cb7
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/errors/ComposerSendMessageNetworkErrors.kt
@@ -0,0 +1,322 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.sending.errors
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority
+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.post
+import ch.protonmail.android.networkmocks.mockwebserver.requests.put
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce
+import ch.protonmail.android.networkmocks.mockwebserver.requests.simulateNoNetwork
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority
+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.e2e.composer.ComposerTests
+import ch.protonmail.android.uitest.helpers.core.TestId
+import ch.protonmail.android.uitest.helpers.core.TestingNotes
+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.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.snackbar.ComposerSnackbar
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.UninstallModules
+import io.mockk.every
+import io.mockk.mockk
+import me.proton.core.auth.domain.usecase.ValidateServerProof
+import org.junit.Ignore
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@TestingNotes("Scope to be expanded once MAILANDR-988 is addressed.")
+@UninstallModules(ServerProofModule::class)
+internal class ComposerSendMessageNetworkErrors : MockedNetworkTest(
+ loginType = LoginTestUserTypes.Paid.FancyCapybara
+), ComposerTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val subject = "A subject"
+ private val messageBody = "A message body"
+
+ @Test
+ @TestId("219650")
+ fun testMessageSendingErrorOnKeyFetching() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = true,
+ useDefaultRecipientKeys = false
+ ) {
+ addMockRequests(
+ get("/core/v4/keys")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 serveOnce true ignoreQueryParams true
+ )
+ }
+
+ val recipients = listOf("example@proton.black")
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ prepareDraft(toRecipients = recipients, subject = subject, body = messageBody)
+ topAppBarSection { tapSendButton() }
+ }
+
+ mailboxRobot {
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } }
+ }
+ }
+
+ @Test
+ @TestId("219651")
+ fun testMessageSendingErrorOnMultipleKeysFetching() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = true,
+ useDefaultSendMessageResponse = true
+ ) {
+ addMockRequests(
+ get("/core/v4/keys?Email=royalcat%40proton.black")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 serveOnce true withPriority MockPriority.Highest
+ )
+ }
+
+ val recipients = listOf("royaldog@proton.black", "royalcat@proton.black")
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ prepareDraft(toRecipients = recipients, subject = subject, body = messageBody)
+ topAppBarSection { tapSendButton() }
+ }
+
+ mailboxRobot {
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } }
+ }
+ }
+
+ @Test
+ @TestId("219652")
+ @Ignore("To be enabled again when MAILANDR-989 is addressed.")
+ fun testMessageSendingErrorOnInvalidKeyFetched() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = true,
+ useDefaultSendMessageResponse = false,
+ useDefaultRecipientKeys = false
+ ) {
+ addMockRequests(
+ get("/core/v4/keys?Email=royalcat%40proton.black")
+ respondWith "/core/v4/keys/keys_219652.json"
+ withStatusCode 200 serveOnce true
+ )
+ }
+
+ val recipients = listOf("royalcat@proton.black")
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ prepareDraft(toRecipients = recipients, subject = subject, body = messageBody)
+ topAppBarSection { tapSendButton() }
+ }
+
+ mailboxRobot {
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } }
+ }
+ }
+
+ @Test
+ @TestId("219653")
+ @Ignore("To be enabled again when MAILANDR-989 is addressed.")
+ fun testMessageSendingErrorOnMultipleInvalidKeysFetched() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = true,
+ useDefaultSendMessageResponse = false
+ ) {
+ addMockRequests(
+ get("/core/v4/keys?Email=royalcat%40proton.black")
+ respondWith "/core/v4/keys/keys_219653.json"
+ withStatusCode 200 serveOnce true withPriority MockPriority.Highest
+ )
+ }
+
+ val recipients = listOf("royalcat@proton.black", "royaldog@proton.black")
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ prepareDraft(toRecipients = recipients, subject = subject, body = messageBody)
+ topAppBarSection { tapSendButton() }
+ }
+
+ mailboxRobot {
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } }
+ }
+ }
+
+ @Test
+ @TestId("222470")
+ fun testMessageSendingWhenOffline() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = false,
+ useDefaultSendMessageResponse = false,
+ useDefaultRecipientKeys = false
+ ) {
+ addMockRequests(
+ post("/mail/v4/messages")
+ simulateNoNetwork true ignoreQueryParams true serveOnce true,
+ put("/mail/v4/messages")
+ simulateNoNetwork true ignoreQueryParams true,
+ post("/mail/v4/messages/*")
+ simulateNoNetwork true matchWildcards true serveOnce true,
+ get("/core/v4/keys")
+ simulateNoNetwork true ignoreQueryParams true
+ )
+ }
+
+ val recipients = listOf("royalcat@proton.black")
+
+ navigator { navigateTo(Destination.Composer) }
+
+ every { networkManager.isConnectedToNetwork() } returns false
+
+ composerRobot {
+ prepareDraft(toRecipients = recipients, subject = subject, body = messageBody)
+ topAppBarSection { tapSendButton() }
+ }
+
+ mailboxRobot {
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageQueued) } }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("219655")
+ fun testMessageSendingWithServerErrorOnLastPost() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = true,
+ useDefaultSendMessageResponse = false
+ ) {
+ addMockRequests(
+ post("/mail/v4/messages/*")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 matchWildcards true
+ )
+ }
+
+ val recipients = listOf("royalcat@proton.black")
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ prepareDraft(toRecipients = recipients, subject = subject, body = messageBody)
+ topAppBarSection { tapSendButton() }
+ }
+
+ mailboxRobot {
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.SendingMessage) } }
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } }
+ }
+ }
+
+ @Test
+ @Ignore("To be enabled again when MAILANDR-1244 is addressed.")
+ @TestId("219656")
+ fun testMessageSendingWithServerErrorOnDraftUpload() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = false,
+ useDefaultSendMessageResponse = false
+ ) {
+ addMockRequests(
+ post("/mail/v4/messages")
+ respondWith "/mail/v4/messages/post/post_messages_base_create_placeholder.json"
+ withStatusCode 200 serveOnce true,
+ put("/mail/v4/messages/*")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 matchWildcards true
+ )
+ }
+
+ val recipients = listOf("royalcat@proton.black")
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ prepareDraft(toRecipients = recipients, subject = subject, body = messageBody)
+ topAppBarSection { tapSendButton() }
+ }
+
+ mailboxRobot {
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } }
+ }
+ }
+
+ @Test
+ @Ignore("To be enabled again when MAILANDR-1244 is addressed.")
+ @TestId("219657")
+ fun testMessageSendingWithServerErrorOnDraftCreation() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultDraftUploadResponse = false,
+ useDefaultSendMessageResponse = false
+ ) {
+ addMockRequests(
+ post("/mail/v4/messages")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503,
+ put("/mail/v4/messages/*")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 matchWildcards true
+ )
+ }
+
+ val recipients = listOf("royalcat@proton.black")
+
+ navigator { navigateTo(Destination.Composer) }
+
+ composerRobot {
+ prepareDraft(toRecipients = recipients, subject = subject, body = messageBody)
+ topAppBarSection { tapSendButton() }
+ }
+
+ mailboxRobot {
+ snackbarSection {
+ verify {
+ isDisplaying(ComposerSnackbar.SendingMessage)
+ isDisplaying(ComposerSnackbar.MessageSentError)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/reply/ComposerReplyConversationTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/reply/ComposerReplyConversationTests.kt
new file mode 100644
index 0000000000..5cd276a14a
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/reply/ComposerReplyConversationTests.kt
@@ -0,0 +1,147 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.sending.reply
+
+import androidx.test.filters.SdkSuppress
+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.post
+import ch.protonmail.android.networkmocks.mockwebserver.requests.put
+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.e2e.composer.ComposerTests
+import ch.protonmail.android.uitest.e2e.mailbox.detail.attachments.inline.EmbeddedImagesTests
+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.robot.common.section.fullscreenLoaderSection
+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.snackbar.ComposerSnackbar
+import ch.protonmail.android.uitest.robot.composer.section.messageBodySection
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.bannerSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerReplyConversationTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara),
+ ComposerTests,
+ EmbeddedImagesTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @SmokeTest
+ @SdkSuppress(minSdkVersion = 29)
+ @TestId("260089")
+ fun testReplyToMessageWithEmbeddedImagesWithConversationModeEnabled() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultMessagesList = false,
+ useDefaultDraftUploadResponse = false,
+ useDefaultSendMessageResponse = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_260089.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_260089.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_260089.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_260089_2.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_260089.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_260089"
+ withStatusCode 200 matchWildcards true serveOnce true
+ withMimeType MimeType.OctetStream,
+ // Draft creation
+ post("/mail/v4/messages")
+ respondWith "/mail/v4/messages/post/post_messages_260089.json"
+ withStatusCode 200 serveOnce true,
+ // Draft upload (1st)
+ put("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/put/put_messages_260089.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ // Final draft upload
+ put("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/put/put_messages_260089_2.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ // Actual sending
+ post("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/post/post_messages_260089_2.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ conversationDetailRobot {
+ messageBodySection { verifyEmbeddedImageLoaded(expectedState = true) }
+ bannerSection { verify { hasBlockedContentBannerNotDisplayed() } }
+
+ messageHeaderSection { tapReplyButton() }
+ }
+
+ composerRobot {
+ fullscreenLoaderSection { waitUntilGone() }
+
+ messageBodySection { typeMessageBody("Reply") }
+ topAppBarSection { tapSendButton() }
+
+ snackbarSection {
+ verify { isDisplaying(ComposerSnackbar.SendingMessage) }
+ verify { isDisplaying(ComposerSnackbar.MessageSent) }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/subject/ComposerSubjectTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/subject/ComposerSubjectTests.kt
new file mode 100644
index 0000000000..4bd8e004cb
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/subject/ComposerSubjectTests.kt
@@ -0,0 +1,160 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.composer.subject
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.e2e.composer.ComposerTests
+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.composer.ComposerRobot
+import ch.protonmail.android.uitest.robot.composer.composerRobot
+import ch.protonmail.android.uitest.robot.composer.section.messageBodySection
+import ch.protonmail.android.uitest.robot.composer.section.subjectSection
+import ch.protonmail.android.uitest.robot.composer.section.verify
+import ch.protonmail.android.uitest.robot.helpers.deviceRobot
+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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ComposerSubjectTests : MockedNetworkTest(), ComposerTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Before
+ fun prelude() {
+ mockWebServer.dispatcher combineWith composerMockNetworkDispatcher()
+ navigator { navigateTo(Destination.Composer) }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("194249", "194250", "194251")
+ fun testRomanNumbersSymbolsCharInputInSubject() {
+ val expectedInput = (RomanLettersRange + RomanNumbersRange + SymbolsRange).joinToString(separator = "")
+
+ composerRobot {
+ typeAndVerifySubject(expectedInput)
+ }
+ }
+
+ @Test
+ @TestId("194252")
+ fun testNonRomanLettersAndNumbersCharInputInSubject() {
+ composerRobot {
+ typeAndVerifySubject(NonRomanChars)
+ }
+ }
+
+ @Test
+ @TestId("194253")
+ fun testEmojiInputInSubject() {
+ composerRobot {
+ typeAndVerifySubject(Emojis)
+ }
+ }
+
+ @Test
+ @TestId("194254", "194255")
+ fun testImeActionInSubject() {
+ composerRobot {
+ subjectSection {
+ typeSubject(DefaultSubject)
+ performImeAction()
+ }
+
+ messageBodySection { verify { hasFocus() } }
+ }
+ }
+
+ @Test
+ @TestId("194260")
+ fun testVeryLongSubjectField() {
+ val expectedInput = StringBuilder().apply {
+ repeat(times = 50) { append(RomanLettersRange + RomanNumbersRange + SymbolsRange) }
+ }.toString()
+
+ composerRobot {
+ typeAndVerifySubject(expectedInput)
+ }
+ }
+
+ @Test
+ @TestId("194265")
+ fun testDeletionInSubject() {
+ composerRobot {
+ subjectSection {
+ typeSubject(DefaultSubject)
+ clearField()
+
+ verify { hasEmptySubject() }
+ }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("194273")
+ fun testSubjectFieldOnActivityRecreation() {
+ composerRobot {
+ subjectSection {
+ typeSubject(DefaultSubject)
+ }
+ }
+
+ deviceRobot { triggerActivityRecreation() }
+
+ composerRobot {
+ subjectSection {
+ verify { hasSubject(DefaultSubject) }
+ }
+ }
+ }
+
+ private fun ComposerRobot.typeAndVerifySubject(expectedInput: String) {
+ subjectSection {
+ typeSubject(expectedInput)
+ verify { hasSubject(expectedInput) }
+ }
+ }
+
+ private companion object {
+
+ val RomanLettersRange = 'A'.rangeTo('z').filter { it.isLetter() }
+ val RomanNumbersRange = 0..9
+ val SymbolsRange = '!'.rangeTo('~').filterNot { it.isLetterOrDigit() }
+ const val NonRomanChars = "ςερτυθιοπασδφγηξκλζχψωβνμ"
+ const val Emojis = "😡😦🥶👻👍"
+
+ const val DefaultSubject = "Subject!!"
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/login/LoginFlowTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/login/LoginFlowTests.kt
new file mode 100644
index 0000000000..ccf98f064b
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/login/LoginFlowTests.kt
@@ -0,0 +1,66 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.login
+
+import ch.protonmail.android.test.annotations.suite.CoreLibraryTest
+import ch.protonmail.android.uitest.BaseTest
+import ch.protonmail.android.uitest.di.LocalhostApi
+import ch.protonmail.android.uitest.di.LocalhostApiModule
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.verify
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.UninstallModules
+import me.proton.core.auth.test.MinimalSignInInternalTests
+import me.proton.core.auth.test.rule.AcceptExternalRule
+import me.proton.core.auth.test.usecase.WaitForPrimaryAccount
+import me.proton.core.network.domain.client.ExtraHeaderProvider
+import me.proton.core.test.quark.Quark
+import me.proton.core.test.quark.data.User
+import org.junit.Rule
+import javax.inject.Inject
+
+@CoreLibraryTest
+@HiltAndroidTest
+@UninstallModules(LocalhostApiModule::class)
+internal class LoginFlowTests : BaseTest(), MinimalSignInInternalTests {
+
+ @JvmField
+ @BindValue
+ @LocalhostApi
+ val localhostApi = false
+
+ override val quark: Quark = BaseTest.quark
+ override val users: User.Users = BaseTest.users
+
+ @get:Rule(order = RuleOrder_21_Injected)
+ val acceptExternalRule = AcceptExternalRule { extraHeaderProvider }
+
+ @Inject
+ lateinit var extraHeaderProvider: ExtraHeaderProvider
+
+ @Inject
+ lateinit var waitForPrimaryAccount: WaitForPrimaryAccount
+
+ override fun verifyAfter() {
+ waitForPrimaryAccount()
+
+ mailboxRobot { verify { isShown() } }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/login/StartupTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/login/StartupTests.kt
new file mode 100644
index 0000000000..2cd151e6c5
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/login/StartupTests.kt
@@ -0,0 +1,59 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.login
+
+import ch.protonmail.android.networkmocks.mockwebserver.MockNetworkDispatcher
+import ch.protonmail.android.networkmocks.mockwebserver.requests.post
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+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.navigator
+import ch.protonmail.android.uitest.helpers.login.LoginType
+import ch.protonmail.android.uitest.robot.helpers.deviceRobot
+import ch.protonmail.android.uitest.robot.helpers.verify
+import dagger.hilt.android.testing.HiltAndroidTest
+import org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+internal open class StartupTests : MockedNetworkTest(loginType = LoginType.LoggedOut) {
+
+ @Test
+ @TestId("224907")
+ fun testBackButtonNavigationOnMainScreen() {
+ // Do not use default values, we only need to serve the sessions API call.
+ mockWebServer.dispatcher = MockNetworkDispatcher().apply {
+ addMockRequests(
+ post("/auth/v4/sessions")
+ respondWith "/auth/v4/sessions/sessions_logged_out_placeholder.json"
+ withStatusCode 200
+ )
+ }
+
+ navigator { openApp() }
+
+ deviceRobot {
+ pressBack()
+
+ verify { isMainActivityNotDisplayed() }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/ConversationMarkAsReadTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/ConversationMarkAsReadTests.kt
new file mode 100644
index 0000000000..4f46412f56
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/ConversationMarkAsReadTests.kt
@@ -0,0 +1,104 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.section.verify
+import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ConversationMarkAsReadTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("78994")
+ fun checkConversationMarkedAsReadWhenLastUnreadMessageIsOpened() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_78994.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_78994.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_78994.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_78994.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ val expectedMessageBody = "Third message"
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify { messageInWebViewContains(expectedMessageBody) }
+ }
+ }
+
+ // Idling is currently not automatically handled when coming from UI Automator interactions.
+ uiDevice.pressBack().also { ComposeTestRuleHolder.rule.waitForIdle() }
+
+ mailboxRobot {
+ listSection { verify { readItemAtPosition(0) } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxAuthenticityBadgeTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxAuthenticityBadgeTests.kt
new file mode 100644
index 0000000000..7039dc289c
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxAuthenticityBadgeTests.kt
@@ -0,0 +1,232 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority
+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.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.MailboxType
+import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.mailbox.section.verify
+import ch.protonmail.android.uitest.robot.menu.menuRobot
+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 org.junit.Test
+
+@RegressionTest
+@UninstallModules(ServerProofModule::class)
+@HiltAndroidTest
+internal class MailboxAuthenticityBadgeTests : MockedNetworkTest(
+ loginType = LoginTestUserTypes.Paid.FancyCapybara
+) {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val mailboxItemProtonOfficial = MailboxListItemEntry(
+ index = 0,
+ avatarInitial = AvatarInitial.WithText("P"),
+ participants = listOf(ParticipantEntry.Common.ProtonOfficial),
+ subject = "Official message",
+ date = "Jul 5, 2023"
+ )
+
+ private val mailboxItemProtonUnofficial = MailboxListItemEntry(
+ index = 1,
+ avatarInitial = AvatarInitial.WithText("P"),
+ participants = listOf(ParticipantEntry.Common.ProtonUnofficial),
+ subject = "Not official message",
+ date = "Jul 5, 2023"
+ )
+
+ private val mailboxItemLongNameOfficial = mailboxItemProtonOfficial.copy(
+ participants = listOf(
+ ParticipantEntry.WithParticipant(
+ name = "ProtonProtonProtonProtonProtonProtonProtonProton",
+ isProton = true
+ )
+ )
+ )
+
+ @Test
+ @SmokeTest
+ @TestId("192128", "192129")
+ fun testAuthenticityBadgeInMailboxInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_192128.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_192128.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ val expectedItems = arrayOf(mailboxItemProtonOfficial, mailboxItemProtonUnofficial)
+
+ navigator { navigateTo(Destination.Inbox) }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(*expectedItems) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("194274")
+ fun testAuthenticityBadgeWithLongNameInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_194274.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_194274.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ navigator { navigateTo(Destination.Inbox) }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(mailboxItemLongNameOfficial) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("192130", "192131", "192132", "192133")
+ fun testAuthenticityBadgeInSentFolder() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_192130.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=7&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_192130.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ val expectedItems = arrayOf(
+ mailboxItemProtonUnofficial.copy(
+ index = 0,
+ participants = listOf(ParticipantEntry.Common.ProtonUnofficial, ParticipantEntry.Common.FreeUser)
+ ),
+ mailboxItemProtonOfficial.copy(
+ index = 1,
+ participants = listOf(ParticipantEntry.Common.ProtonOfficial, ParticipantEntry.Common.FreeUser)
+ ),
+ mailboxItemProtonUnofficial.copy(index = 2),
+ mailboxItemProtonOfficial.copy(index = 3)
+ )
+
+ navigator { navigateTo(Destination.Inbox) }
+
+ menuRobot {
+ openSidebarMenu()
+ openSent()
+ }
+
+ mailboxRobot {
+ topAppBarSection { verify { isMailbox(MailboxType.Sent) } }
+
+ listSection {
+ verify { listItemsAreShown(*expectedItems) }
+ }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("192134", "192135")
+ fun testAuthenticityBadgeInMailboxInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_192134.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_192134.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ val expectedItems = arrayOf(mailboxItemProtonOfficial, mailboxItemProtonUnofficial)
+
+ navigator { navigateTo(Destination.Inbox) }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(*expectedItems) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("194275")
+ fun testAuthenticityBadgeWithLongNameInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_194275.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_194275.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ navigator { navigateTo(Destination.Inbox) }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(mailboxItemLongNameOfficial) }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxFlowTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxFlowTest.kt
new file mode 100644
index 0000000000..da7e1e4d8f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxFlowTest.kt
@@ -0,0 +1,99 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.MockedNetworkTest
+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.models.mailbox.MailboxType
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.stickyHeaderSection
+import ch.protonmail.android.uitest.robot.mailbox.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.mailbox.section.verify
+import ch.protonmail.android.uitest.robot.mailbox.verify
+import ch.protonmail.android.uitest.robot.menu.menuRobot
+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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@UninstallModules(ServerProofModule::class)
+@HiltAndroidTest
+internal class MailboxFlowTest : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Before
+ fun setupDispatcher() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher()
+ navigator { navigateTo(Destination.Inbox) }
+ }
+
+ @Test
+ fun openMailboxAndSwitchLocation() {
+ mailboxRobot {
+ verify { isShown() }
+ }
+
+ menuRobot {
+ openSidebarMenu()
+ openDrafts()
+ }
+
+ mailboxRobot {
+ topAppBarSection { verify { isMailbox(MailboxType.Drafts) } }
+ }
+
+ menuRobot {
+ openSidebarMenu()
+ openAllMail()
+ }
+
+ mailboxRobot {
+ topAppBarSection { verify { isMailbox(MailboxType.AllMail) } }
+ }
+ }
+
+ /*
+ * This could be improved by injecting an account with some known messages and performing
+ * verifications on the messages list to ensure filtering actually works as expected
+ */
+ @Test
+ fun filterUnreadMessages() {
+ mailboxRobot {
+ verify { isShown() }
+
+ stickyHeaderSection {
+ verify { unreadFilterIsDisplayed() }
+ filterUnreadMessages()
+ verify { unreadFilterIsSelected() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxParticipantsTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxParticipantsTest.kt
new file mode 100644
index 0000000000..97e4f434b9
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxParticipantsTest.kt
@@ -0,0 +1,148 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class MailboxParticipantsTest : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val expectedInboxListEntries = arrayOf(
+ MailboxListItemEntry(
+ index = 0,
+ avatarInitial = AvatarInitial.WithText("M"),
+ participants = listOf(
+ ParticipantEntry.WithParticipant("mobileappsuitesting3@proton.black")
+ ),
+ subject = "Test no contact, empty sender name",
+ date = "Mar 20, 2023"
+ ),
+ MailboxListItemEntry(
+ index = 1,
+ avatarInitial = AvatarInitial.WithText("?"),
+ participants = listOf(ParticipantEntry.NoSender),
+ subject = "Test no contact, empty",
+ date = "Mar 20, 2023"
+ ),
+ MailboxListItemEntry(
+ index = 2,
+ avatarInitial = AvatarInitial.WithText("U"),
+ participants = listOf(ParticipantEntry.WithParticipant("UI Tests Contact 1")),
+ subject = "From contact with no sender name",
+ date = "Mar 20, 2023"
+ )
+ )
+
+ @Test
+ @TestId("77426")
+ fun checkAvatarInitialsWithMissingParticipantDetailsInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultContacts = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_77426.json"
+ withStatusCode 200,
+ get("/contacts/v4/contacts")
+ respondWith "/contacts/v4/contacts/contacts_77426.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/contacts/v4/contacts/emails")
+ respondWith "/contacts/v4/contacts/emails/contacts-emails_77426.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_77426.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(*expectedInboxListEntries) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("77427")
+ fun checkAvatarInitialsWithMissingParticipantDetailsInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultContacts = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_77427.json"
+ withStatusCode 200,
+ get("/contacts/v4/contacts")
+ respondWith "/contacts/v4/contacts/contacts_77427.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/contacts/v4/contacts/emails")
+ respondWith "/contacts/v4/contacts/emails/contacts-emails_77427.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_77427.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(*expectedInboxListEntries) }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxSwitchTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxSwitchTests.kt
new file mode 100644
index 0000000000..2561ddcfec
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxSwitchTests.kt
@@ -0,0 +1,101 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.section.verify
+import ch.protonmail.android.uitest.robot.menu.menuRobot
+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 org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class MailboxSwitchTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val expectedFirstSentItem = MailboxListItemEntry(
+ index = 0,
+ avatarInitial = AvatarInitial.WithText("M"),
+ participants = listOf(ParticipantEntry.WithParticipant("Mobile Apps UI Testing 2")),
+ subject = "Test message TOP",
+ date = "Apr 3, 2023"
+ )
+
+ @Test
+ @TestId("183527")
+ fun checkMailboxSwitchConversationToMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_183527.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_183527.json"
+ withStatusCode 200 serveOnce true,
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=7&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_183527.json"
+ withStatusCode 200 serveOnce true withNetworkDelay 2000
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection { scrollToBottom() }
+ }
+
+ menuRobot {
+ openSidebarMenu()
+ openSent()
+ }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(expectedFirstSentItem) }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MessageLoadingTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MessageLoadingTests.kt
new file mode 100644
index 0000000000..3488dfa271
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MessageLoadingTests.kt
@@ -0,0 +1,301 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice
+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 org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class MessageLoadingTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("66392", "225747/2")
+ fun checkMessageLoadedInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_66392.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_66392.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_66392.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ val expectedMessageBody = "Bye and hello"
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify { messageInWebViewContains(expectedMessageBody) }
+ }
+ }
+
+ uiDevice.pressBack()
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify { messageInWebViewContains(expectedMessageBody) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("66393", "225747")
+ fun checkMessageLoadedInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_66393.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_66393.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_66393.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_66393.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ val expectedMessageBody = "Hello once again"
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify { messageInWebViewContains(expectedMessageBody) }
+ }
+ }
+
+ uiDevice.pressBack()
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify { messageInWebViewContains(expectedMessageBody) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("66394")
+ fun checkLongMessageLoadedInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_66394.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_66394.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_66394.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ val expectedMessageBody = "Lorem ipsum"
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify { messageInWebViewContains(expectedMessageBody) }
+ }
+ }
+
+ uiDevice.pressBack()
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify { messageInWebViewContains(expectedMessageBody) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("66395")
+ fun checkLongMessageLoadedInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_66395.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_66395.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_66395.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_66395.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ val expectedMessageBody = "Lorem ipsum"
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify { messageInWebViewContains(expectedMessageBody) }
+ }
+ }
+
+ uiDevice.pressBack()
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify { messageInWebViewContains(expectedMessageBody) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("78993")
+ fun checkMostRecentUnreadMessageIsOpenedInConversation() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_78993.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_78993.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_78993.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_78993.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ val expectedMessageBody = "Third message"
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify { messageInWebViewContains(expectedMessageBody) }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/allmail/AllMailMailboxFolderColorsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/allmail/AllMailMailboxFolderColorsTests.kt
new file mode 100644
index 0000000000..1d85026f5f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/allmail/AllMailMailboxFolderColorsTests.kt
@@ -0,0 +1,322 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.allmail
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.folders.MailFolderEntry
+import ch.protonmail.android.uitest.models.folders.Tint
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.section.verify
+import ch.protonmail.android.uitest.robot.menu.MenuRobot
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class AllMailMailboxFolderColorsTests : MockedNetworkTest() {
+
+ private val menuRobot = MenuRobot()
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val firstMessageEntry = MailboxListItemEntry(
+ index = 0,
+ avatarInitial = AvatarInitial.WithText("M"),
+ participants = listOf(ParticipantEntry.WithParticipant("mobileappsuitesting3")),
+ subject = "Parent folder message",
+ date = "Mar 28, 2023"
+ )
+
+ private val secondMessageEntry = MailboxListItemEntry(
+ index = 1,
+ avatarInitial = AvatarInitial.WithText("M"),
+ participants = listOf(ParticipantEntry.WithParticipant("mobileappsuitesting2")),
+ subject = "Child folder message",
+ date = "Mar 21, 2023"
+ )
+
+ @Test
+ @TestId("80673")
+ fun checkFolderColorInAllMailWithSettingEnabledAndParentInheritingDisabledInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_80673.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_80673.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_80673.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ verifyMailboxItems(
+ firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Carrot),
+ secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Fern)
+ )
+ }
+
+ @Test
+ @TestId("80674")
+ fun checkFolderColorInAllMailWithSettingEnabledAndParentInheritingDisabledInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_80674.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_80674.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_80674.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ verifyMailboxItems(
+ firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Carrot),
+ secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Fern)
+ )
+ }
+
+ @Test
+ @TestId("80675")
+ fun checkFolderColorInAllMailWithSettingEnabledAndParentInheritingEnabledInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_80675.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_80675.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_80675.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ verifyMailboxItems(
+ firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Carrot),
+ secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Carrot)
+ )
+ }
+
+ @Test
+ @TestId("80676")
+ fun checkFolderColorInAllMailWithSettingEnabledAndParentInheritingEnabledInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_80676.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_80676.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_80676.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ verifyMailboxItems(
+ firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Carrot),
+ secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Carrot)
+ )
+ }
+
+ @Test
+ @TestId("80677")
+ fun checkFolderColorInAllMailWithSettingDisabledAndParentInheritingEnabledInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_80677.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_80677.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_80677.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ verifyMailboxItems(
+ firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor),
+ secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor)
+ )
+ }
+
+ @Test
+ @TestId("80678")
+ fun checkFolderColorInAllMailWithSettingDisabledAndParentInheritingEnabledInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_80678.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_80678.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_80678.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ verifyMailboxItems(
+ firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor),
+ secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor)
+ )
+ }
+
+ @Test
+ @TestId("80679")
+ fun checkFolderColorInAllMailWithSettingDisabledAndParentInheritingDisabledInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_80679.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_80679.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_80679.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ verifyMailboxItems(
+ firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor),
+ secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor)
+ )
+ }
+
+ @Test
+ @TestId("80680")
+ fun checkFolderColorInAllMailWithSettingDisabledAndParentInheritingDisabledInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_80680.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_80680.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_80680.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ verifyMailboxItems(
+ firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor),
+ secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor)
+ )
+ }
+
+ private fun verifyMailboxItems(firstLocationIcon: MailFolderEntry, secondLocationIcon: MailFolderEntry) {
+ navigator { navigateTo(Destination.Inbox) }
+
+ val expectedMailboxEntries = arrayOf(
+ firstMessageEntry.copy(locationIcons = listOf(firstLocationIcon)),
+ secondMessageEntry.copy(locationIcons = listOf(secondLocationIcon))
+ )
+
+ menuRobot
+ .openSidebarMenu()
+ .openAllMail()
+ .listSection { verify { listItemsAreShown(*expectedMailboxEntries) } }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentConversationModeTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentConversationModeTests.kt
new file mode 100644
index 0000000000..183c2e5c53
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentConversationModeTests.kt
@@ -0,0 +1,119 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments
+
+import androidx.test.filters.SdkSuppress
+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.withNetworkDelay
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.common.section.snackbarSection
+import ch.protonmail.android.uitest.robot.common.section.verify
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.model.MessageDetailSnackbar
+import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection
+import ch.protonmail.android.uitest.robot.detail.section.conversation.messagesCollapsedSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Ignore
+import org.junit.Test
+
+@RegressionTest
+@UninstallModules(ServerProofModule::class)
+@HiltAndroidTest
+internal class AttachmentConversationModeTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @SdkSuppress(minSdkVersion = 29)
+ @Ignore("To be addressed with MAILANDR-1276")
+ @TestId("194318")
+ fun testMultipleAttachmentDownloadingInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_194318.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_194318.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_194318.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_194318_2.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_194318"
+ withStatusCode 200 matchWildcards true serveOnce true
+ withMimeType MimeType.OctetStream withNetworkDelay 150_000L
+ )
+ }
+
+ val expectedSnackbar = MessageDetailSnackbar.MultipleDownloadsWarning
+
+ navigator { navigateTo(Destination.MailDetail()) }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ attachmentsSection {
+ tapItem()
+
+ verify { hasLoaderDisplayedForItem() }
+ }
+
+ messageHeaderSection { collapseMessage() }
+ messagesCollapsedSection { openMessageAtIndex(0) }
+
+ messageBodySection { waitUntilMessageIsShown() }
+ attachmentsSection { tapItem() }
+
+ snackbarSection {
+ verify { isDisplaying(expectedSnackbar) }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentDetailsMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentDetailsMainTests.kt
new file mode 100644
index 0000000000..675ada4940
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentDetailsMainTests.kt
@@ -0,0 +1,232 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments
+
+import androidx.test.filters.SdkSuppress
+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.withNetworkDelay
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.detailTopBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+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.storage
+import ch.protonmail.android.uitest.robot.helpers.section.verify
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+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 org.junit.Test
+
+@RegressionTest
+@UninstallModules(ServerProofModule::class)
+@HiltAndroidTest
+internal class AttachmentDetailsMainTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("189705")
+ fun testAttachmentLoaderIconShownOnFetching() {
+ 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_189705.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_189705.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_small_jpg"
+ withStatusCode 200 matchWildcards true withMimeType MimeType.OctetStream withNetworkDelay 5000
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ attachmentsSection {
+ tapItem()
+ verify { hasLoaderDisplayedForItem() }
+ }
+ }
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 30)
+ @TestId("194341")
+ fun testAttachmentLoaderIsStillShownOnMessageReopening() {
+ 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_194341.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_194341.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_small_jpg"
+ withStatusCode 200 matchWildcards true withMimeType MimeType.OctetStream withNetworkDelay 5000
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail()) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ attachmentsSection {
+ tapItem()
+ }
+
+ detailTopBarSection { tapBackButton() }
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ attachmentsSection {
+ verify { hasLoaderDisplayedForItem() }
+ }
+ }
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 30)
+ @TestId("194278")
+ fun testFilesArePresentInTheDownloadFolder() {
+ 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_194278.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_194278.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_small_jpg"
+ withStatusCode 200 matchWildcards true withMimeType MimeType.OctetStream withNetworkDelay 1000L
+ )
+ }
+
+ val expectedImageFileName = "An attached image.jpeg"
+
+ navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ attachmentsSection {
+ tapItem()
+
+ verify {
+ hasLoaderDisplayedForItem()
+ hasLoaderNotDisplayedForItem()
+ }
+ }
+ }
+
+ deviceRobot {
+ storage {
+ verify { containsFileInDownloadsWithName(expectedImageFileName) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("194303", "194344")
+ fun testAttachmentIsNotRefetchedIfAlreadyDownloadedExists() {
+ 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_194303.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_194303.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_small_jpg"
+ withStatusCode 200 matchWildcards true serveOnce true
+ withNetworkDelay 1000L withMimeType MimeType.OctetStream
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail()) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ attachmentsSection {
+ tapItem()
+ verify {
+ hasLoaderDisplayedForItem()
+ hasLoaderNotDisplayedForItem()
+ }
+
+ tapItem()
+ verify { hasLoaderNotDisplayedForItem() }
+ }
+ }
+
+ deviceRobot {
+ intents { verify { actionViewIntentWasLaunched(times = 2) } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentDownloadNotificationsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentDownloadNotificationsTests.kt
new file mode 100644
index 0000000000..276e4cc98d
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentDownloadNotificationsTests.kt
@@ -0,0 +1,159 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments
+
+import androidx.test.filters.SdkSuppress
+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.withNetworkDelay
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.detail.section.verify
+import ch.protonmail.android.uitest.robot.helpers.deviceRobot
+import ch.protonmail.android.uitest.robot.helpers.models.NotificationEntry
+import ch.protonmail.android.uitest.robot.helpers.section.deviceSoftKeys
+import ch.protonmail.android.uitest.robot.helpers.section.intents
+import ch.protonmail.android.uitest.robot.helpers.section.notificationsSection
+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 org.junit.Test
+
+@RegressionTest
+@SdkSuppress(maxSdkVersion = 31)
+@UninstallModules(ServerProofModule::class)
+@HiltAndroidTest
+internal class AttachmentDownloadNotificationsTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val expectedNotification = NotificationEntry(
+ title = "Downloading attachment",
+ isClearable = false
+ )
+
+ @Test
+ @TestId("189706")
+ fun testForegroundNotificationWhenAttachmentIsDownloading() {
+ 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_189706.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_189706.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_small_jpg"
+ withStatusCode 200 matchWildcards true withMimeType MimeType.OctetStream withNetworkDelay 150_000L
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ attachmentsSection {
+ tapItem()
+ verify { hasLoaderDisplayedForItem() }
+ }
+ }
+
+ deviceRobot {
+ deviceSoftKeys { pressHomeButton() }
+
+ notificationsSection {
+ verify { hasNotificationDisplayed(expectedNotification) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("189707", "194335")
+ fun testForegroundNotificationIsGoneAfterSuccessfulDownload() {
+ 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_189707.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_189707.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_small_jpg"
+ withStatusCode 200 matchWildcards true withMimeType MimeType.OctetStream withNetworkDelay 5000L
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail()) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ attachmentsSection {
+ tapItem()
+ verify { hasLoaderDisplayedForItem() }
+ }
+ }
+
+ deviceRobot {
+ deviceSoftKeys { pressHomeButton() }
+
+ notificationsSection {
+ verify {
+ hasNotificationDisplayed(expectedNotification)
+ hasNoNotificationsDisplayed()
+ }
+ }
+
+ intents {
+ verify { actionViewIntentWasNotLaunched() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentErrorsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentErrorsTests.kt
new file mode 100644
index 0000000000..ec113604ab
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentErrorsTests.kt
@@ -0,0 +1,234 @@
+/*
+ * 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 .
+ */
+
+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.withNetworkDelay
+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.common.section.snackbarSection
+import ch.protonmail.android.uitest.robot.common.section.verify
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.model.MessageDetailSnackbar
+import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+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 org.junit.Test
+
+@RegressionTest
+@UninstallModules(ServerProofModule::class)
+@HiltAndroidTest
+internal class AttachmentErrorsTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @SmokeTest
+ @TestId("194315", "194316")
+ fun testMultipleAttachmentsAreNotHandledInParallel() {
+ 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_194315.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_194315.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_png"
+ withStatusCode 200 matchWildcards true serveOnce true
+ withMimeType MimeType.OctetStream withNetworkDelay 2000L
+ )
+ }
+
+ val expectedSnackbar = MessageDetailSnackbar.MultipleDownloadsWarning
+
+ navigator { navigateTo(Destination.MailDetail()) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ attachmentsSection {
+ tapItem(position = 0)
+ verify { hasLoaderDisplayedForItem() }
+
+ tapItem(position = 1)
+ }
+
+ snackbarSection {
+ verify { isDisplaying(expectedSnackbar) }
+ waitUntilDismisses(expectedSnackbar)
+ }
+
+ attachmentsSection { verify { hasLoaderNotDisplayedForItem(position = 0) } }
+ }
+
+ deviceRobot {
+ intents { verify { actionViewIntentWasLaunched() } }
+ }
+ }
+
+ @Test
+ @TestId("194354")
+ fun testManualDownloadRetry() {
+ 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_194354.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_194354.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 403 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_png"
+ withStatusCode 200 matchWildcards true serveOnce true
+ withMimeType MimeType.OctetStream
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail()) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ attachmentsSection { tapItem() }
+
+ snackbarSection { verify { isDisplaying(MessageDetailSnackbar.FailedToGetAttachment) } }
+
+ attachmentsSection { tapItem() }
+
+ deviceRobot {
+ intents { verify { actionViewIntentWasLaunched() } }
+ }
+ }
+ }
+
+ @Test
+ @TestId("194355")
+ fun testDownloadIsNotRetriedOn403() {
+ 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_194355.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_194355.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 403 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_png"
+ withStatusCode 200 matchWildcards true serveOnce true
+ withMimeType MimeType.OctetStream
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail()) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ attachmentsSection { tapItem() }
+
+ snackbarSection { verify { isDisplaying(MessageDetailSnackbar.FailedToGetAttachment) } }
+
+ deviceRobot {
+ intents { verify { actionViewIntentWasNotLaunched() } }
+ }
+
+ attachmentsSection { tapItem() }
+
+ deviceRobot {
+ intents { verify { actionViewIntentWasLaunched() } }
+ }
+ }
+ }
+
+ @Test
+ @TestId("194355/2")
+ fun test500StatusCodeOnDownloadError() {
+ 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_194355.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_194355.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 500 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_png"
+ withStatusCode 200 matchWildcards true serveOnce true
+ withMimeType MimeType.OctetStream
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail()) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ attachmentsSection { tapItem() }
+
+ deviceRobot {
+ intents { verify { actionViewIntentWasLaunched() } }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentMessageModeTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentMessageModeTests.kt
new file mode 100644
index 0000000000..ed767d384c
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentMessageModeTests.kt
@@ -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 .
+ */
+
+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) } }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentMultipleDownloadTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentMultipleDownloadTests.kt
new file mode 100644
index 0000000000..cde26f7938
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentMultipleDownloadTests.kt
@@ -0,0 +1,191 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments
+
+import androidx.test.filters.SdkSuppress
+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.withNetworkDelay
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.detailTopBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+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 ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+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 org.junit.Test
+
+@RegressionTest
+@UninstallModules(ServerProofModule::class)
+@HiltAndroidTest
+internal class AttachmentMultipleDownloadTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @SdkSuppress(minSdkVersion = 30)
+ @TestId("194333", "194338")
+ fun testMultipleAttachmentWithSequentialDownloads() {
+ 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_194333.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_194333.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_png"
+ withStatusCode 200 matchWildcards true serveOnce true withMimeType MimeType.OctetStream,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_txt"
+ withStatusCode 200 matchWildcards true serveOnce true withMimeType MimeType.OctetStream
+ )
+ }
+
+ val firstExpectedMimeType = "image/png"
+ val secondExpectedMimeType = "text/plain"
+
+ navigator { navigateTo(Destination.MailDetail()) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ attachmentsSection {
+ tapItem()
+ verify { hasLoaderNotDisplayedForItem() }
+ }
+
+ deviceRobot {
+ intents {
+ verify { actionViewIntentWasLaunched(mimeType = firstExpectedMimeType) }
+ }
+ }
+
+ attachmentsSection {
+ tapItem(position = 1)
+ verify { hasLoaderNotDisplayedForItem(position = 1) }
+ }
+
+ deviceRobot {
+ intents {
+ verify {
+ actionViewIntentWasLaunched(mimeType = secondExpectedMimeType)
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ @TestId("194342", "194343", "194326")
+ fun testAttachmentsFromDifferentMessages() {
+ 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_194342.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_194342.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_194342_2.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_png"
+ withStatusCode 200 matchWildcards true serveOnce true
+ withNetworkDelay 150_000L withMimeType MimeType.OctetStream,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_small_jpg"
+ withStatusCode 200 matchWildcards true serveOnce true withMimeType MimeType.OctetStream
+ )
+ }
+
+ val expectedLaunchedMimeType = "image/jpeg"
+ val expectedNotLaunchedMimeType = "image/png"
+
+ navigator { navigateTo(Destination.MailDetail()) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ attachmentsSection {
+ tapItem()
+ verify { hasLoaderDisplayedForItem() }
+ }
+
+ detailTopBarSection { tapBackButton() }
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(position = 1) }
+ }
+
+ messageDetailRobot {
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ attachmentsSection {
+ tapItem()
+ verify { hasLoaderNotDisplayedForItem() }
+ }
+ }
+
+ deviceRobot {
+ intents {
+ verify {
+ actionViewIntentWasLaunched(mimeType = expectedLaunchedMimeType)
+ actionViewIntentWasNotLaunched(mimeType = expectedNotLaunchedMimeType)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/ConversationDetailEmbeddedImagesTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/ConversationDetailEmbeddedImagesTests.kt
new file mode 100644
index 0000000000..6980dba195
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/ConversationDetailEmbeddedImagesTests.kt
@@ -0,0 +1,334 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments.inline
+
+import androidx.test.filters.SdkSuppress
+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.common.section.snackbarSection
+import ch.protonmail.android.uitest.robot.common.section.verify
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.model.MessageDetailSnackbar
+import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailItemEntry
+import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailSummaryEntry
+import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection
+import ch.protonmail.android.uitest.robot.detail.section.bannerSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ConversationDetailEmbeddedImagesTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara),
+ EmbeddedImagesTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @SmokeTest
+ @TestId("203101", "203695")
+ fun testConversationDetailEmbeddedImagesNotLoadedWithSettingOff() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203101.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_203101.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_203101.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203101.json"
+ withStatusCode 200 matchWildcards true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ conversationDetailRobot {
+ messageBodySection { verifyEmbeddedImageLoaded(expectedState = false) }
+
+ bannerSection { verify { hasBlockedEmbeddedImagesBannerDisplayed() } }
+ }
+ }
+
+ @Test
+ @TestId("203102", "203108")
+ fun testConversationDetailEmbeddedImagesBodyLoadingError() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203102.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_203102.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_203104.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 matchWildcards true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ conversationDetailRobot {
+ snackbarSection {
+ verify { isDisplaying(MessageDetailSnackbar.FailedToLoadMessage) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("203104", "203110")
+ fun testConversationDetailEmbeddedImagesBodyDecryptionError() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203104.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_203104.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_203104.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203104.json"
+ withStatusCode 200 matchWildcards true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ conversationDetailRobot {
+ snackbarSection { verify { isDisplaying(MessageDetailSnackbar.FailedToDecryptMessage) } }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @SdkSuppress(minSdkVersion = 29)
+ @TestId("203105", "203692")
+ fun testConversationDetailEmbeddedImagesAreLoaded() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203105.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_203105.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_203105.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203105.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_203105"
+ withStatusCode 200 matchWildcards true serveOnce true
+ withMimeType MimeType.OctetStream
+ )
+ }
+
+ val expectedSummary = AttachmentDetailSummaryEntry(summary = "1 file", size = "1.5 kB")
+ val expectedEntry = AttachmentDetailItemEntry(index = 0, fileName = "image.png", fileSize = "1.5 kB")
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ conversationDetailRobot {
+ messageBodySection { verifyEmbeddedImageLoaded(expectedState = true) }
+
+ bannerSection { verify { hasBlockedContentBannerNotDisplayed() } }
+
+ attachmentsSection {
+ verify {
+ hasSummaryDetails(expectedSummary)
+ hasAttachments(expectedEntry)
+ }
+ }
+ }
+ }
+
+ @Test
+ @TestId("203106")
+ fun testConversationDetailEmbeddedImagesErrorsUponDownload() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203106.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_203106.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_203106.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203106.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/attachments/*")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ conversationDetailRobot {
+ messageBodySection { verifyEmbeddedImageLoaded(expectedState = false) }
+ }
+ }
+
+ @Test
+ @TestId("203107")
+ fun testConversationDetailEmbeddedImagesErrorsUponDecryption() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203107.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_203107.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_203107.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203107.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_203107"
+ withStatusCode 200 matchWildcards true serveOnce true
+ withMimeType MimeType.OctetStream
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ conversationDetailRobot {
+ messageBodySection { verifyEmbeddedImageLoaded(expectedState = false) }
+ }
+ }
+
+ @Test
+ @TestId("203696")
+ fun testConversationDetailEmbeddedImagesBlockedBannerIsNotDisplayedWhenNoEmbeddedImagesArePresent() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203696.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_203696.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_203696.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203696.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ bannerSection { verify { hasBlockedContentBannerNotDisplayed() } }
+ }
+ }
+
+ @Test
+ @TestId("203699", "203700/2")
+ fun testConversationDetailEmbeddedImagesBlockedBannerIsDisplayedOnExternalEmails() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203700_2.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_203700.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_203700.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203700.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ bannerSection { verify { hasBlockedEmbeddedImagesBannerDisplayed() } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/EmbeddedImagesTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/EmbeddedImagesTests.kt
new file mode 100644
index 0000000000..46550a4539
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/EmbeddedImagesTests.kt
@@ -0,0 +1,34 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments.inline
+
+import ch.protonmail.android.uitest.robot.detail.section.MessageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+
+internal interface EmbeddedImagesTests {
+
+ fun MessageBodySection.verifyEmbeddedImageLoaded(expectedNumber: Int = 1, expectedState: Boolean) {
+ waitUntilMessageIsShown()
+
+ verify {
+ hasEmbeddedImages(expectedNumber)
+ hasEmbeddedImagesSuccessfullyLoaded(expectedState)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/MessageDetailEmbeddedImagesTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/MessageDetailEmbeddedImagesTests.kt
new file mode 100644
index 0000000000..be368505a0
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/MessageDetailEmbeddedImagesTests.kt
@@ -0,0 +1,311 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments.inline
+
+import androidx.test.filters.SdkSuppress
+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.common.section.snackbarSection
+import ch.protonmail.android.uitest.robot.common.section.verify
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.model.MessageDetailSnackbar
+import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailItemEntry
+import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailSummaryEntry
+import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection
+import ch.protonmail.android.uitest.robot.detail.section.bannerSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Ignore
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class MessageDetailEmbeddedImagesTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara),
+ EmbeddedImagesTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @SmokeTest
+ @TestId("203101/2", "203694")
+ fun testMessageDetailEmbeddedImagesNotLoadedWithSettingOff() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203101_2.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_203101.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203101.json"
+ withStatusCode 200 matchWildcards true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ messageDetailRobot {
+ messageBodySection { verifyEmbeddedImageLoaded(expectedState = false) }
+
+ bannerSection { verify { hasBlockedEmbeddedImagesBannerDisplayed() } }
+ }
+ }
+
+ @Ignore("MAILANDR-753")
+ @Test
+ @TestId("203102/2", "203108/2")
+ fun testMessageDetailEmbeddedImagesBodyLoadingError() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203102_2.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_203102.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 matchWildcards true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ messageDetailRobot {
+ snackbarSection { verify { isDisplaying(MessageDetailSnackbar.FailedToLoadMessage) } }
+ }
+ }
+
+ @Ignore("MAILANDR-753")
+ @Test
+ @TestId("203104/2", "203110/2")
+ fun testMessageDetailEmbeddedImagesBodyDecryptionError() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203104_2.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_203104.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203104.json"
+ withStatusCode 200 matchWildcards true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ messageDetailRobot {
+ snackbarSection { verify { isDisplaying(MessageDetailSnackbar.FailedToDecryptMessage) } }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @SdkSuppress(minSdkVersion = 29)
+ @TestId("203105/2", "203693")
+ fun testMessageDetailEmbeddedImagesAreLoaded() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203105_2.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_203105.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203105.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_203105"
+ withStatusCode 200 matchWildcards true serveOnce true
+ withMimeType MimeType.OctetStream
+ )
+ }
+
+ val expectedSummary = AttachmentDetailSummaryEntry(summary = "1 file", size = "1.5 kB")
+ val expectedEntry = AttachmentDetailItemEntry(index = 0, fileName = "image.png", fileSize = "1.5 kB")
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ messageDetailRobot {
+ messageBodySection { verifyEmbeddedImageLoaded(expectedState = true) }
+
+ bannerSection { verify { hasBlockedContentBannerNotDisplayed() } }
+
+ attachmentsSection {
+ verify {
+ hasSummaryDetails(expectedSummary)
+ hasAttachments(expectedEntry)
+ }
+ }
+ }
+ }
+
+ @Test
+ @TestId("203106/2")
+ fun testMessageDetailEmbeddedImagesErrorsUponDownload() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203106_2.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_203106.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203106.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/attachments/*")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ messageDetailRobot {
+ messageBodySection { verifyEmbeddedImageLoaded(expectedState = false) }
+ }
+ }
+
+ @Test
+ @TestId("203107/2")
+ fun testMessageDetailEmbeddedImagesErrorsUponDecryption() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203107_2.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_203107.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203107.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/attachments/*")
+ respondWith "/mail/v4/attachments/attachment_203107"
+ withStatusCode 200 matchWildcards true serveOnce true
+ withMimeType MimeType.OctetStream
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ messageDetailRobot {
+ messageBodySection { verifyEmbeddedImageLoaded(expectedState = false) }
+ }
+ }
+
+ @Test
+ @TestId("203696/2")
+ fun testMessageDetailEmbeddedImagesBlockedBannerIsNotDisplayedWhenNoEmbeddedImagesArePresent() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203696_2.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_203696.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203696.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ bannerSection { verify { hasBlockedContentBannerNotDisplayed() } }
+ }
+ }
+
+ @Test
+ @TestId("203700")
+ fun testMessageDetailEmbeddedImagesBlockedBannerIsDisplayedOnExternalEmails() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_203700.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_203700.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_203700.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ bannerSection { verify { hasBlockedEmbeddedImagesBannerDisplayed() } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/AuthenticityBadgeDetailTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/AuthenticityBadgeDetailTests.kt
new file mode 100644
index 0000000000..4119a9d88d
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/AuthenticityBadgeDetailTests.kt
@@ -0,0 +1,42 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.authbadge
+
+import ch.protonmail.android.uitest.robot.detail.section.MessageHeaderSection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+
+internal interface AuthenticityBadgeDetailTests {
+
+ fun MessageHeaderSection.verifyProtonSenderInMessageHeader(
+ senderName: String = "Proton",
+ shouldDisplayBadge: Boolean
+ ) {
+ verify {
+ hasSenderName(senderName)
+ hasAuthenticityBadge(shouldDisplayBadge)
+ }
+
+ expandHeader()
+
+ verify {
+ hasSenderName(senderName)
+ hasAuthenticityBadge(shouldDisplayBadge)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/ConversationDetailAuthenticityBadgeTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/ConversationDetailAuthenticityBadgeTests.kt
new file mode 100644
index 0000000000..9bb02f87c2
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/ConversationDetailAuthenticityBadgeTests.kt
@@ -0,0 +1,204 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.authbadge
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.conversation.messagesCollapsedSection
+import ch.protonmail.android.uitest.robot.detail.section.conversation.verify
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Test
+
+@RegressionTest
+@UninstallModules(ServerProofModule::class)
+@HiltAndroidTest
+internal class ConversationDetailAuthenticityBadgeTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), AuthenticityBadgeDetailTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("192140", "192141")
+ fun testAuthBadgeInConversationMessageHeaderWhenIsProtonOfficial() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_192140.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_192140.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_192140.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_192140.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ messageHeaderSection {
+ verifyProtonSenderInMessageHeader(shouldDisplayBadge = true)
+ }
+ }
+ }
+
+ @Test
+ @TestId("192140/2", "192142", "192143")
+ fun testAuthBadgeInConversationMessageHeaderWhenIsNotProtonOfficial() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_192140.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_192142.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_192142.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_192142.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ messageHeaderSection {
+ verifyProtonSenderInMessageHeader(shouldDisplayBadge = false)
+ }
+ }
+ }
+
+ @Test
+ @TestId("192144", "192145")
+ fun testAuthBadgeInConversationCollapsedExpandedHeaderWhenIsProtonOfficial() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_192144.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_192144.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_192144.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_192144.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) }
+
+ conversationDetailRobot {
+ verifyCollapsedAndExpandedHeader(index = 0, shouldDisplayBadge = true)
+ }
+ }
+
+ @Test
+ @TestId("192144/2", "192146", "192147")
+ fun testAuthBadgeInConversationCollapsedExpandedHeaderWhenIsNotProtonOfficial() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_192144.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_192146.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_192146.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_192146.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) }
+
+ conversationDetailRobot {
+ verifyCollapsedAndExpandedHeader(index = 0, shouldDisplayBadge = false)
+ }
+ }
+
+ private fun ConversationDetailRobot.verifyCollapsedAndExpandedHeader(
+ index: Int,
+ sender: String = "Proton",
+ shouldDisplayBadge: Boolean
+ ) {
+
+ messageBodySection { waitUntilMessageIsShown() }
+
+ messageHeaderSection { expanded { collapse() } }
+
+ messagesCollapsedSection {
+ verify {
+ senderNameIsDisplayed(index, sender)
+ authenticityBadgeIsDisplayed(index, shouldDisplayBadge)
+ }
+
+ openMessageAtIndex(index)
+ }
+
+ messageBodySection { waitUntilMessageIsShown() }
+
+ messageHeaderSection {
+ verify {
+ hasSenderName(sender)
+ hasAuthenticityBadge(shouldDisplayBadge)
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/MessageDetailAuthenticityBadgeTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/MessageDetailAuthenticityBadgeTests.kt
new file mode 100644
index 0000000000..c670830468
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/MessageDetailAuthenticityBadgeTests.kt
@@ -0,0 +1,111 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.authbadge
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection
+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 org.junit.Test
+
+@RegressionTest
+@UninstallModules(ServerProofModule::class)
+@HiltAndroidTest
+internal class MessageDetailAuthenticityBadgeTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), AuthenticityBadgeDetailTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("192136", "192137")
+ fun testAuthBadgeInMessageHeaderWhenIsProtonOfficial() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_192136.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_192136.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_192136.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ messageHeaderSection {
+ verifyProtonSenderInMessageHeader(shouldDisplayBadge = true)
+ }
+ }
+ }
+
+ @Test
+ @TestId("192136/2", "192138", "192139")
+ fun testAuthBadgeInMessageHeaderWhenIsNotProtonOfficial() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_192136.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_192138.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_192138.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ messageHeaderSection {
+ verifyProtonSenderInMessageHeader(shouldDisplayBadge = false)
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/ConversationDetailHtmlSanitizationTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/ConversationDetailHtmlSanitizationTests.kt
new file mode 100644
index 0000000000..806f335112
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/ConversationDetailHtmlSanitizationTests.kt
@@ -0,0 +1,86 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ConversationDetailHtmlSanitizationTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("189699")
+ fun checkHtmlSanitizationInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_189699.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_189699.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_189699.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ conversationDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify { hasHtmlContentSanitised() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/ConversationDetailRemoteContentTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/ConversationDetailRemoteContentTests.kt
new file mode 100644
index 0000000000..5a831e9523
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/ConversationDetailRemoteContentTests.kt
@@ -0,0 +1,269 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.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.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.bannerSection
+import ch.protonmail.android.uitest.robot.detail.section.conversation.messagesCollapsedSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ConversationDetailRemoteContentTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) {
+
+ private val expectedBodyText = "Various img elements"
+ private val expectedBodyTextLastMessage = "Various img elements (2)"
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @SmokeTest
+ @TestId("184211", "212682")
+ fun checkRemoteContentBlockedInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_184211.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_184211.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_184211.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_184211.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ conversationDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify {
+ messageInWebViewContains(expectedBodyText)
+ hasRemoteImageLoaded(false)
+ }
+ }
+
+ bannerSection { verify { hasBlockedRemoteImagesBannerDisplayed() } }
+ }
+ }
+
+ @Test
+ @TestId("184212")
+ fun checkRemoteContentBlockedWithMultipleMessagesInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_184212.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_184212.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_184212.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_184212.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_184212_2.json"
+ withStatusCode 200 matchWildcards true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ conversationDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify {
+ messageInWebViewContains(expectedBodyTextLastMessage)
+ hasRemoteImageLoaded(false)
+ }
+ }
+
+ bannerSection { verify { hasBlockedRemoteImagesBannerDisplayed() } }
+
+ messageHeaderSection {
+ expanded { collapse() }
+ }
+
+ messagesCollapsedSection {
+ scrollToTop()
+ openMessageAtIndex(0)
+ }
+
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify {
+ messageInWebViewContains(expectedBodyText)
+ hasRemoteImageLoaded(false)
+ }
+ }
+
+ bannerSection { verify { hasBlockedRemoteImagesBannerDisplayed() } }
+ }
+ }
+
+ @Test
+ @TestId("212683/2")
+ fun checkRemoteContentBannerNotShownWhenNoRemoteContentIsDisplayedInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_212683_2.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_212683.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_212683.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212683.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bannerSection { verify { hasBlockedContentBannerNotDisplayed() } }
+ }
+ }
+
+ @Test
+ @TestId("212684/2")
+ fun checkCombinedBannerIsShownWhenBothRemoteContentAndEmbeddedImagesAreBlockedInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_212684_2.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_212684.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_212684.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212684.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify {
+ hasRemoteImageLoaded(false)
+ hasEmbeddedImagesSuccessfullyLoaded(false)
+ }
+ }
+
+ bannerSection { verify { hasBlockerEmbeddedAndRemoteImagesBannerDisplayed() } }
+ }
+ }
+
+ @Test
+ @TestId("212686/2", "212687")
+ fun checkRemoteContentBlockedFromExternalAddressInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_212686_2.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_212686.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_212686.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212686.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify {
+ messageInWebViewContains(expectedBodyText)
+ hasRemoteImageLoaded(false)
+ }
+ }
+
+ bannerSection { verify { hasBlockedRemoteImagesBannerDisplayed() } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MessageDetailHtmlSanitizationTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MessageDetailHtmlSanitizationTests.kt
new file mode 100644
index 0000000000..de6755bde8
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MessageDetailHtmlSanitizationTests.kt
@@ -0,0 +1,83 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class MessageDetailHtmlSanitizationTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("189700")
+ fun checkHtmlSanitizationInMessageMode() {
+ 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_189700.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_189700.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify { hasHtmlContentSanitised() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MessageDetailRemoteContentTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MessageDetailRemoteContentTests.kt
new file mode 100644
index 0000000000..c1586a8cb8
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MessageDetailRemoteContentTests.kt
@@ -0,0 +1,189 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+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.bannerSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class MessageDetailRemoteContentTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) {
+
+ private val expectedBodyText = "Various img elements"
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("184210", "212681")
+ fun checkRemoteContentBlockedInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_184210.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_184210.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_184210.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify {
+ messageInWebViewContains(expectedBodyText)
+ hasRemoteImageLoaded(false)
+ }
+ }
+
+ bannerSection { verify { hasBlockedRemoteImagesBannerDisplayed() } }
+ }
+ }
+
+ @Test
+ @TestId("212683")
+ fun checkRemoteContentBannerNotShownWhenNoRemoteContentIsDisplayedInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_212683.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212683.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212683.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bannerSection { verify { hasBlockedContentBannerNotDisplayed() } }
+ }
+ }
+
+ @Test
+ @TestId("212684")
+ fun checkCombinedBannerIsShownWhenBothRemoteContentAndEmbeddedImagesAreBlockedInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_212684.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212684.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212684.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify {
+ hasRemoteImageLoaded(false)
+ hasEmbeddedImagesSuccessfullyLoaded(false)
+ }
+ }
+
+ bannerSection { verify { hasBlockerEmbeddedAndRemoteImagesBannerDisplayed() } }
+ }
+ }
+
+ @Test
+ @TestId("212686")
+ fun checkRemoteContentBlockedFromExternalAddressInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_212686.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212686.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212686.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify {
+ messageInWebViewContains(expectedBodyText)
+ hasRemoteImageLoaded(false)
+ }
+ }
+
+ bannerSection { verify { hasBlockedRemoteImagesBannerDisplayed() } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MockedDetailRemoteContentTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MockedDetailRemoteContentTests.kt
new file mode 100644
index 0000000000..38a7cc0f26
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MockedDetailRemoteContentTests.kt
@@ -0,0 +1,250 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent
+
+import arrow.core.right
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample
+import ch.protonmail.android.mailmessage.domain.model.DecryptedMessageBody
+import ch.protonmail.android.mailmessage.domain.model.MessageId
+import ch.protonmail.android.mailmessage.domain.model.MimeType
+import ch.protonmail.android.mailmessage.domain.usecase.GetDecryptedMessageBody
+import ch.protonmail.android.networkmocks.assets.RawAssets
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.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.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.bannerSection
+import ch.protonmail.android.uitest.robot.detail.section.conversation.messagesCollapsedSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.UninstallModules
+import io.mockk.coEvery
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import me.proton.core.auth.domain.usecase.ValidateServerProof
+import me.proton.core.user.domain.entity.UserAddress
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Separate suite to mock decryption when UI testing as we can't possibly rely on links that might expire.
+ * Will be improved once we intercept the remote content calls via MockWebServer (currently not possible).
+ */
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class MockedDetailRemoteContentTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) {
+
+ private val expectedBodyText = "Various img elements"
+ private val expectedBodyTextLastMessage = "Various img elements (2)"
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @JvmField
+ @BindValue // GetDecryptedMessageBody needs to be mocked to make sure content is passed as expected to the WebView.
+ val decryptedMessageBody: GetDecryptedMessageBody = mockk().apply { mockDecryptedBody() }
+
+ @Before
+ fun reset() {
+ unmockkAll()
+ }
+
+ @Test
+ @TestId("184207", "212679")
+ fun checkRemoteContentNotBlockedInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_184207.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_184207.json"
+ withStatusCode 200 matchWildcards true ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_184207.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify {
+ messageInWebViewContains(expectedBodyText)
+ hasRemoteImageLoaded(true)
+ }
+ }
+
+ bannerSection { verify { hasBlockedContentBannerNotDisplayed() } }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("184206", "212680")
+ fun checkRemoteContentNotBlockedInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_184206.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_184206.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_184206.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_184206.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ conversationDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify {
+ messageInWebViewContains(expectedBodyText)
+ hasRemoteImageLoaded(true)
+ }
+ }
+
+ bannerSection { verify { hasBlockedContentBannerNotDisplayed() } }
+ }
+ }
+
+ @Test
+ @TestId("184208", "184209")
+ fun checkRemoteContentNotBlockedOnMultipleMessagesInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_184209.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_184209.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_184209.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_184209.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_184209_2.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ decryptedMessageBody.mockDecryptedBody(PlaceholderMockTwo)
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ conversationDetailRobot {
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify {
+ messageInWebViewContains(expectedBodyTextLastMessage)
+ hasRemoteImageLoaded(true)
+ }
+ }
+
+ bannerSection { verify { hasBlockedContentBannerNotDisplayed() } }
+
+ messageHeaderSection {
+ expanded { collapse() }
+ }
+
+ decryptedMessageBody.mockDecryptedBody(PlaceholderMockOne)
+
+ messagesCollapsedSection {
+ scrollToTop()
+ openMessageAtIndex(0)
+ }
+
+ messageBodySection {
+ waitUntilMessageIsShown()
+
+ verify {
+ messageInWebViewContains(expectedBodyText)
+ hasRemoteImageLoaded(true)
+ }
+ }
+
+ bannerSection { verify { hasBlockedContentBannerNotDisplayed() } }
+ }
+ }
+
+ private fun GetDecryptedMessageBody.mockDecryptedBody(assetName: String = PlaceholderMockOne) {
+ coEvery { this@mockDecryptedBody.invoke(any(), any()) } returns getHtmlMessageBodyContent(assetName).right()
+ }
+
+ private fun getHtmlMessageBodyContent(assetName: String): DecryptedMessageBody {
+ val content = requireNotNull(RawAssets.getRawContentForPath(HtmlAssetsPath + assetName)) {
+ "Unable to retrieve content for file '$assetName'."
+ }
+
+ return DecryptedMessageBody(
+ MessageId("html-message-id"),
+ String(content),
+ MimeType.Html,
+ emptyList(),
+ UserAddressSample.PrimaryAddress
+ )
+ }
+
+ companion object {
+
+ private const val HtmlAssetsPath = "assets/network-mocks/html-assets/"
+ const val PlaceholderMockOne = "html_remote_content_placeholder.html"
+ const val PlaceholderMockTwo = "html_remote_content_placeholder_2.html"
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/ConversationDetailWebViewTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/ConversationDetailWebViewTests.kt
new file mode 100644
index 0000000000..23f446ef56
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/ConversationDetailWebViewTests.kt
@@ -0,0 +1,120 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent.webview
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.test.utils.mocks.WebViewProviderMocks.mockWebViewAvailabilityOnDevice
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ConversationDetailWebViewTests : MockedNetworkTest(), MockedWebViewTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("225746")
+ fun testWebViewProviderNotPresentShowsWarningMessageInPlaceOfBodyInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_225746.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_225746.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_225746.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_225746.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ mockWebViewAvailabilityOnDevice(isPackagePresent = false)
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ conversationDetailRobot {
+ messageBodySection {
+ verify { isShowingMissingWebViewWarning() }
+ }
+ }
+ }
+
+ @Test
+ @TestId("225746/2", "225748")
+ fun testWebViewProviderPresentButDisabledShowsWarningMessageInPlaceOfBodyInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_225746.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_225746.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_225746.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_225746.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ mockWebViewAvailabilityOnDevice(isPackagePresent = true, isPackageEnabled = false)
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ conversationDetailRobot {
+ messageBodySection {
+ verify { isShowingMissingWebViewWarning() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/MessageDetailWebViewTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/MessageDetailWebViewTests.kt
new file mode 100644
index 0000000000..fc47133e96
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/MessageDetailWebViewTests.kt
@@ -0,0 +1,112 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent.webview
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+import ch.protonmail.android.test.utils.mocks.WebViewProviderMocks.mockWebViewAvailabilityOnDevice
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Test
+
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class MessageDetailWebViewTests : MockedNetworkTest(), MockedWebViewTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("225746/3")
+ fun testWebViewProviderNotPresentShowsWarningMessageInPlaceOfBodyInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_225746_3.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_225746_3.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_225746_3.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ mockWebViewAvailabilityOnDevice(isPackagePresent = false)
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ verify { isShowingMissingWebViewWarning() }
+ }
+ }
+ }
+
+ @Test
+ @TestId("225746/4", "225748/2")
+ fun testWebViewProviderPresentButDisabledShowsWarningMessageInPlaceOfBodyInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_225746_3.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_225746_3.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_225746_3.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ mockWebViewAvailabilityOnDevice(isPackagePresent = true, isPackageEnabled = false)
+
+ navigator {
+ navigateTo(Destination.MailDetail())
+ }
+
+ messageDetailRobot {
+ messageBodySection {
+ verify { isShowingMissingWebViewWarning() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/MockedWebViewTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/MockedWebViewTests.kt
new file mode 100644
index 0000000000..20f2b2b329
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/MockedWebViewTests.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent.webview
+
+import android.webkit.WebView
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import org.junit.After
+import org.junit.Before
+
+interface MockedWebViewTests {
+
+ @Before
+ fun mockWebView() {
+ mockkStatic(WebView::class)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/labelas/ConversationDetailMoveToBottomSheetDismissalTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/labelas/ConversationDetailMoveToBottomSheetDismissalTests.kt
new file mode 100644
index 0000000000..0cdd3b075e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/labelas/ConversationDetailMoveToBottomSheetDismissalTests.kt
@@ -0,0 +1,201 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.labelas
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection
+import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+import ch.protonmail.android.uitest.robot.detail.verify
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ConversationDetailMoveToBottomSheetDismissalTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("79353")
+ fun checkConversationMoveToBottomSheetDismissalWithBackButton() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_79353.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_79353.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_79353.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_79353.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ verify { isShown() }
+ }
+ }
+
+ // Physical/soft key press is required by this test case.
+ uiDevice.pressBack()
+
+ conversationDetailRobot {
+ verify { conversationDetailScreenIsShown() }
+
+ moveToBottomSheetSection {
+ verify { isHidden() }
+ }
+ }
+ }
+
+ @Test
+ @TestId("79355")
+ fun checkConversationMoveToBottomSheetDismissalWithExternalTap() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_79355.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_79355.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_79355.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_79355.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ verify { isShown() }
+ }
+
+ // Tap outside the view.
+ messageHeaderSection {
+ expandHeader()
+ }
+
+ verify { conversationDetailScreenIsShown() }
+
+ moveToBottomSheetSection {
+ verify { isHidden() }
+ }
+ }
+ }
+
+ @Test
+ @TestId("458329")
+ fun checkConversationMoveToBottomSheetDismissalWithDoneButton() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_458329.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_458329.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_458329.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_458329.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ verify { isShown() }
+
+ tapDoneButton()
+ verify { isHidden() }
+ }
+
+ verify { conversationDetailScreenIsShown() }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/labelas/MessageDetailLabelAsBottomSheetDismissalTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/labelas/MessageDetailLabelAsBottomSheetDismissalTests.kt
new file mode 100644
index 0000000000..7d96d6b1aa
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/labelas/MessageDetailLabelAsBottomSheetDismissalTests.kt
@@ -0,0 +1,140 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.labelas
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+import ch.protonmail.android.uitest.robot.detail.verify
+import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class MessageDetailLabelAsBottomSheetDismissalTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("79354/2")
+ fun checkMessageLabelAsBottomSheetDismissalWithBackButton() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_79354.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_79354.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_79354.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(messagePosition = 0))
+ }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ bottomBarSection {
+ openLabelAsBottomSheet()
+
+ verify { labelAsBottomSheetExists() }
+ }
+ }
+
+ uiDevice.pressBack()
+
+ messageDetailRobot {
+ bottomBarSection {
+ verify { labelAsBottomSheetIsDismissed() }
+ }
+
+ verify { messageDetailScreenIsShown() }
+ }
+ }
+
+ @Test
+ @TestId("79356/2")
+ fun checkMessageLabelAsBottomSheetDismissalWithExternalTap() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_79356.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_79356.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_79356.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(messagePosition = 0))
+ }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ bottomBarSection {
+ openLabelAsBottomSheet()
+
+ verify { labelAsBottomSheetExists() }
+ }
+
+ // Tap outside the view.
+ messageHeaderSection { expandHeader() }
+
+ bottomBarSection {
+ verify { labelAsBottomSheetIsDismissed() }
+ }
+
+ verify { messageDetailScreenIsShown() }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/ConversationDetailLabelAsBottomSheetDismissalTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/ConversationDetailLabelAsBottomSheetDismissalTests.kt
new file mode 100644
index 0000000000..f06039a25c
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/ConversationDetailLabelAsBottomSheetDismissalTests.kt
@@ -0,0 +1,158 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+import ch.protonmail.android.uitest.robot.detail.verify
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ConversationDetailLabelAsBottomSheetDismissalTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("79353/2")
+ fun checkConversationLabelAsBottomSheetDismissalWithBackButton() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_79353.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_79353.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_79353.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_79353.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ bottomBarSection {
+ openLabelAsBottomSheet()
+ verify { labelAsBottomSheetExists() }
+ }
+ }
+
+ // Physical/soft key press is required by this test case.
+ uiDevice.pressBack()
+
+ conversationDetailRobot {
+ verify { conversationDetailScreenIsShown() }
+
+ bottomBarSection {
+ verify { labelAsBottomSheetIsDismissed() }
+ }
+ }
+ }
+
+ @Test
+ @TestId("79355/2")
+ fun checkConversationLabelAsBottomSheetDismissalWithExternalTap() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_79355.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_79355.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_79355.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_79355.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+
+ bottomBarSection {
+ openLabelAsBottomSheet()
+
+ verify { labelAsBottomSheetExists() }
+ }
+
+ // Tap outside the view.
+ messageHeaderSection {
+ expandHeader()
+ }
+
+ verify { conversationDetailScreenIsShown() }
+
+ bottomBarSection {
+ verify { labelAsBottomSheetIsDismissed() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/ConversationDetailMoveToBottomSheetMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/ConversationDetailMoveToBottomSheetMainTests.kt
new file mode 100644
index 0000000000..1d64ba83ae
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/ConversationDetailMoveToBottomSheetMainTests.kt
@@ -0,0 +1,92 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ConversationDetailMoveToBottomSheetMainTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("185409")
+ fun checkMoveToBottomSheetComponentsInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_185409.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_base_placeholder.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_185409.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ verify {
+ headerTextIsShown()
+ doneButtonIsShown()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetActionTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetActionTests.kt
new file mode 100644
index 0000000000..65ba187683
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetActionTests.kt
@@ -0,0 +1,295 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.folders.Tint
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry
+import ch.protonmail.android.uitest.robot.common.section.snackbarSection
+import ch.protonmail.android.uitest.robot.common.section.verify
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.model.MessageDetailSnackbar
+import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntry
+import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntry.SystemFolders.Archive
+import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntry.SystemFolders.Inbox
+import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.emptyListSection
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.section.verify
+import ch.protonmail.android.uitest.robot.menu.menuRobot
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class DetailMoveToBottomSheetActionTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val firstCustomFolder = MoveToBottomSheetFolderEntry(
+ index = 0, name = "Test Folder", iconTint = Tint.WithColor.Carrot
+ )
+ private val secondCustomFolder = MoveToBottomSheetFolderEntry(
+ index = 1, name = "Child Test Folder", iconTint = Tint.WithColor.Fern
+ )
+
+ private val expectedMailboxItem = MailboxListItemEntry(
+ index = 0,
+ avatarInitial = AvatarInitial.WithText("M"),
+ participants = listOf(ParticipantEntry.WithParticipant("mobileappsuitesting3")),
+ subject = "Move this somewhere else",
+ date = "Mar 28, 2023"
+ )
+
+ @Test
+ @TestId("185418")
+ fun checkMoveToBottomSheetSystemToSystemFolder() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_185418.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=6&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 serveOnce true,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_185418.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=6&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_185418_2.json"
+ withStatusCode 200 serveOnce true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_185418.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_185418.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Archive)
+ }
+
+ mailboxRobot {
+ emptyListSection { verify { isShown() } }
+ }
+
+ moveMessageToFolder(
+ startingFolder = Inbox,
+ destinationFolder = Archive
+ )
+ }
+
+ @Test
+ @TestId("185419")
+ fun checkMoveToBottomSheetCustomToCustomFolder() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false,
+ useDefaultMailReadResponses = true
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_185419.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_185419.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=testid&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_185419.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=childid&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_185419_2.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_185419.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_185419.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ moveMessageToFolder(
+ startingFolder = firstCustomFolder,
+ destinationFolder = secondCustomFolder
+ )
+ }
+
+ @Test
+ @TestId("185419/2", "185421")
+ fun checkMoveToBottomSheetCustomToSystemFolder() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false,
+ useDefaultMailReadResponses = true
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_185419.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_185419.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=testid&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_185419.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=6&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_185419_3.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_185419_3.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_185419.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ moveMessageToFolder(
+ startingFolder = firstCustomFolder,
+ destinationFolder = Archive
+ )
+ }
+
+ @Test
+ @TestId("185419/3", "185423")
+ fun checkMoveToBottomSheetSystemToCustomFolder() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false,
+ useDefaultMailReadResponses = true
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_185419.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_185419.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=6&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_185419_3.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=childid&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_185419_2.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_185419_2.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_185419_3.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ moveMessageToFolder(
+ startingFolder = Archive,
+ destinationFolder = secondCustomFolder
+ )
+ }
+
+ private fun moveMessageToFolder(
+ index: Int = 0,
+ startingFolder: MoveToBottomSheetFolderEntry,
+ destinationFolder: MoveToBottomSheetFolderEntry
+ ) {
+ val snackbar = MessageDetailSnackbar.ConversationMovedToFolder(destinationFolder.name)
+
+ menuRobot {
+ openSidebarMenu()
+ openFolderWithName(startingFolder.name)
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(index) }
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ selectFolderWithName(destinationFolder.name)
+ tapDoneButton()
+
+ verify { isHidden() }
+ }
+
+ snackbarSection {
+ verify { isDisplaying(snackbar) }
+ }
+ }
+
+ menuRobot {
+ openSidebarMenu()
+ openFolderWithName(destinationFolder.name)
+ }
+
+ mailboxRobot {
+ listSection { verify { listItemsAreShown(expectedMailboxItem) } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetLabelsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetLabelsTests.kt
new file mode 100644
index 0000000000..2ad38e64ea
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetLabelsTests.kt
@@ -0,0 +1,206 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.folders.MailLabelEntry
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntry.SystemFolders.Trash
+import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.section.verify
+import ch.protonmail.android.uitest.robot.menu.menuRobot
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class DetailMoveToBottomSheetLabelsTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val startMailboxItem = MailboxListItemEntry(
+ index = 0,
+ avatarInitial = AvatarInitial.WithText("M"),
+ participants = listOf(ParticipantEntry.WithParticipant("mobileappsuitesting2")),
+ labels = listOf(MailLabelEntry(index = 0, name = "Test Label")),
+ subject = "Example test",
+ date = "Mar 6, 2023"
+ )
+
+ private val finalMailboxItem = startMailboxItem.copy(labels = emptyList())
+
+ @Test
+ @TestId("185425")
+ fun checkMoveToBottomSheetMoveToTrashFolder() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultLabels = false,
+ useDefaultCustomFolders = false,
+ useDefaultMailReadResponses = true
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_185425.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=1")
+ respondWith "/core/v4/labels/labels-type1_185425.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_base_placeholder_empty.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_185425.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=3&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_185425_2.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_185425.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_185425.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(startMailboxItem) }
+ clickMessageByPosition(0)
+ }
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ selectFolderWithName(Trash.name)
+ tapDoneButton()
+ }
+ }
+
+ menuRobot {
+ openSidebarMenu()
+ openTrash()
+ }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(finalMailboxItem) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("185425/2", "185426")
+ fun checkMoveToBottomSheetMoveToSpamFolder() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultLabels = false,
+ useDefaultCustomFolders = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_185425.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=1")
+ respondWith "/core/v4/labels/labels-type1_185425.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_base_placeholder_empty.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_185425.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=4&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_185425_3.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_185425.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_185425.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(startMailboxItem) }
+ clickMessageByPosition(0)
+ }
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ selectFolderWithName(Trash.name)
+ tapDoneButton()
+ }
+ }
+
+ menuRobot {
+ openSidebarMenu()
+ openSpam()
+ }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(finalMailboxItem) }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetMainTests.kt
new file mode 100644
index 0000000000..f80f654a45
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetMainTests.kt
@@ -0,0 +1,259 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.models.folders.Tint
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntry
+import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class DetailMoveToBottomSheetMainTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val firstCustomFolder = MoveToBottomSheetFolderEntry(
+ index = 0, name = "Test Folder", iconTint = Tint.WithColor.Carrot
+ )
+ private val secondCustomFolder = MoveToBottomSheetFolderEntry(
+ index = 1, name = "Child Test Folder", iconTint = Tint.WithColor.Fern
+ )
+ private val systemFolders = arrayOf(
+ MoveToBottomSheetFolderEntry.SystemFolders.Inbox,
+ MoveToBottomSheetFolderEntry.SystemFolders.Archive,
+ MoveToBottomSheetFolderEntry.SystemFolders.Spam,
+ MoveToBottomSheetFolderEntry.SystemFolders.Trash
+ )
+
+ @Test
+ @TestId("185411")
+ fun checkMoveToBottomSheetComponentsWithNoCustomFolders() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_185411.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_base_placeholder_empty.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_base_placeholder.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_185411.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ verify { hasFolders(*systemFolders) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("185412")
+ fun checkMoveToBottomSheetComponentsWithCustomFolders() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false,
+ useDefaultMailReadResponses = true
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_185412.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_185412.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_base_placeholder.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_185412.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ val expectedFolders = arrayOf(firstCustomFolder, secondCustomFolder).combineWithSystemFolders()
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ verify { hasFolders(*expectedFolders) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("185414")
+ fun checkMoveToBottomSheetComponentsSelection() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_185414.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_base_placeholder_empty.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_base_placeholder.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_185414.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ val expectedSelectedFolders = systemFolders.map {
+ if (it == MoveToBottomSheetFolderEntry.SystemFolders.Trash) it.copy(isSelected = true) else it
+ }.toTypedArray()
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ selectFolderAtPosition(MoveToBottomSheetFolderEntry.SystemFolders.Trash.index)
+ verify { hasFolders(*expectedSelectedFolders) }
+
+ tapDoneButton()
+ verify { isHidden() }
+ }
+ }
+ }
+
+ @Test
+ @TestId("185415")
+ fun checkMoveToBottomSheetSelectionIsGoneAfterDismissal() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultCustomFolders = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_185415.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_base_placeholder_empty.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/conversations/*")
+ respondWith "/mail/v4/conversations/conversation-id/conversation-id_base_placeholder.json"
+ withStatusCode 200 matchWildcards true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_185415.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ val expectedSelectedFolders = systemFolders.map {
+ if (it == MoveToBottomSheetFolderEntry.SystemFolders.Trash) it.copy(isSelected = true) else it
+ }.toTypedArray()
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ conversationDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ selectFolderAtPosition(MoveToBottomSheetFolderEntry.SystemFolders.Trash.index)
+ verify { hasFolders(*expectedSelectedFolders) }
+ dismiss()
+ }
+
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ verify { hasFolders(*systemFolders) }
+ }
+ }
+ }
+
+ private fun Array.combineWithSystemFolders(): Array {
+ return systemFolders.map {
+ it.copy(index = size + it.index)
+ }.toTypedArray()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/MessageDetailMoveToBottomSheetDismissalTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/MessageDetailMoveToBottomSheetDismissalTests.kt
new file mode 100644
index 0000000000..185102c188
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/MessageDetailMoveToBottomSheetDismissalTests.kt
@@ -0,0 +1,176 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection
+import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+import ch.protonmail.android.uitest.robot.detail.verify
+import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class MessageDetailMoveToBottomSheetDismissalTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("79354")
+ fun checkMessageMoveToBottomSheetDismissalWithBackButton() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_79354.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_79354.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_79354.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(messagePosition = 0))
+ }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ verify { isShown() }
+ }
+ }
+
+ // Physical/soft key press is required by this test case.
+ uiDevice.pressBack()
+
+ messageDetailRobot {
+ moveToBottomSheetSection {
+ verify { isHidden() }
+ }
+
+ verify { messageDetailScreenIsShown() }
+ }
+ }
+
+ @Test
+ @TestId("79356")
+ fun checkMessageMoveToBottomSheetDismissalWithExternalTap() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_79356.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_79356.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_79356.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(messagePosition = 0))
+ }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ verify { isShown() }
+ }
+
+ // Tap outside the view.
+ messageHeaderSection { expandHeader() }
+
+ moveToBottomSheetSection {
+ verify { isHidden() }
+ }
+
+ verify { messageDetailScreenIsShown() }
+ }
+ }
+
+ @Test
+ @TestId("485329")
+ fun checkMessageMoveToBottomSheetDismissalWithDoneButton() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_458330.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_458330.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_458330.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(messagePosition = 0))
+ }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ verify { isShown() }
+
+ tapDoneButton()
+ verify { isHidden() }
+ }
+
+ verify { messageDetailScreenIsShown() }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/MessageDetailMoveToBottomSheetMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/MessageDetailMoveToBottomSheetMainTests.kt
new file mode 100644
index 0000000000..d0029cb565
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/MessageDetailMoveToBottomSheetMainTests.kt
@@ -0,0 +1,89 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection
+import ch.protonmail.android.uitest.robot.detail.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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class MessageDetailMoveToBottomSheetMainTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("185410")
+ fun checkMoveToBottomSheetComponentsInMessageMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_185410.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_185410.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_185410.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.MailDetail(0))
+ }
+
+ messageDetailRobot {
+ messageBodySection { waitUntilMessageIsShown() }
+ bottomBarSection { openMoveToBottomSheet() }
+
+ moveToBottomSheetSection {
+ verify {
+ headerTextIsShown()
+ doneButtonIsShown()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/DraftsMailboxTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/DraftsMailboxTests.kt
new file mode 100644
index 0000000000..781eedc95b
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/DraftsMailboxTests.kt
@@ -0,0 +1,182 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.drafts
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class DraftsMailboxTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("80395")
+ fun checkParticipantNameInDraftFoldersWhenNotSpecified() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_80395.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=8&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_80395.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ val expectedMailboxEntry = MailboxListItemEntry(
+ index = 0,
+ avatarInitial = AvatarInitial.Draft,
+ participants = listOf(ParticipantEntry.NoRecipient),
+ subject = "Test subject",
+ date = "Apr 3, 2023"
+ )
+
+ navigator {
+ navigateTo(Destination.Drafts)
+ }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(expectedMailboxEntry) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("80396")
+ fun checkParticipantNameInDraftFoldersWhenSpecifiedAndIsAContact() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultContacts = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_80396.json"
+ withStatusCode 200,
+ get("/contacts/v4/contacts")
+ respondWith "/contacts/v4/contacts/contacts_80396.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/contacts/v4/contacts/emails")
+ respondWith "/contacts/v4/contacts/emails/contacts-emails_80396.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=8&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_80396.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ val expectedMailboxEntry = MailboxListItemEntry(
+ index = 0,
+ avatarInitial = AvatarInitial.Draft,
+ participants = listOf(ParticipantEntry.WithParticipant("UI Tests Contact 1")),
+ subject = "Test subject",
+ date = "Apr 3, 2023"
+ )
+
+ navigator {
+ navigateTo(Destination.Drafts)
+ }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(expectedMailboxEntry) }
+ }
+ }
+ }
+
+ @Test
+ @TestId("80397")
+ fun checkParticipantNameInDraftFoldersWhenSpecifiedAndIsNotAContact() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultMailSettings = false,
+ useDefaultContacts = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_80397.json"
+ withStatusCode 200,
+ get("/contacts/v4/contacts")
+ respondWith "/contacts/v4/contacts/contacts_80397.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/contacts/v4/contacts/emails")
+ respondWith "/contacts/v4/contacts/emails/contacts-emails_80397.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=8&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_80397.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ val expectedMailboxEntry = MailboxListItemEntry(
+ index = 0,
+ avatarInitial = AvatarInitial.Draft,
+ participants = listOf(ParticipantEntry.WithParticipant("Mobile Apps UI Testing 2")),
+ subject = "Test subject",
+ date = "Apr 3, 2023"
+ )
+
+ navigator {
+ navigateTo(Destination.Drafts)
+ }
+
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(expectedMailboxEntry) }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsErrorTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsErrorTests.kt
new file mode 100644
index 0000000000..68b629d53f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsErrorTests.kt
@@ -0,0 +1,304 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.drafts
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+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.simulateNoNetwork
+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.TestingNotes
+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.common.section.fullscreenLoaderSection
+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.topAppBarSection
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class OpenExistingDraftsErrorTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara),
+ OpenExistingDraftsTest {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val expectedToChip = RecipientChipEntry(
+ index = 0,
+ text = "aa@bb.cc",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Valid
+ )
+
+ private val expectedSubject = "Test subject"
+ private val expectedMessageBody = "Some text"
+
+ @Test
+ @TestId("212667")
+ fun openingDraftInOfflineModeWithNoLocalCacheShowsSnackbarWarning() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212667.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ simulateNoNetwork true matchWildcards true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.EditDraft())
+ }
+
+ composerRobot {
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.DraftOutOfSync) } }
+ verifyEmptyFields()
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("212668")
+ fun openingDraftWhenBeReturnsErrorWithNoLocalCacheShowsSnackbarWarning() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212668.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 matchWildcards true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.EditDraft())
+ }
+
+ composerRobot {
+ verifyEmptyFields()
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.DraftOutOfSync) } }
+ }
+ }
+
+ @Test
+ @TestId("212669")
+ fun openingDraftWithDecryptionErrorShowsOutOfSyncWarning() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212669.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212669.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.EditDraft())
+ }
+
+ composerRobot {
+ verifyEmptyFields()
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.DraftOutOfSync) } }
+ }
+ }
+
+ @Test
+ @TestingNotes("Add snackbar check once it's implemented (see MAILANDR-862)")
+ @TestId("212670")
+ fun openingDraftInOfflineModeWithLocalCacheShowsCachedData() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212670.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212670.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212670.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ simulateNoNetwork true matchWildcards true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.EditDraft())
+ }
+
+ verifyLoadedCachedData()
+ }
+
+ @Test
+ @SmokeTest
+ @TestingNotes("Add snackbar check once it's implemented (see MAILANDR-862)")
+ @TestId("212673")
+ fun openingDraftOnBeErrorWithLocalCacheShowsCachedData() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212673.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212673.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212673.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 matchWildcards true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.EditDraft())
+ }
+
+ verifyLoadedCachedData()
+ }
+
+ @Test
+ @TestId("212674")
+ fun openingDraftOnDecryptionErrorWithLocalCacheShowsEmptyFields() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212674.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212674.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212674_2.json"
+ withStatusCode 200 matchWildcards true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.EditDraft())
+ }
+
+ composerRobot {
+ verifyPrefilledFields(
+ toRecipientChip = expectedToChip,
+ subject = expectedSubject,
+ messageBody = expectedMessageBody
+ )
+
+ topAppBarSection { tapCloseButton() }
+ }
+
+ mailboxRobot { listSection { clickMessageByPosition(0) } }
+
+ composerRobot {
+ fullscreenLoaderSection { waitUntilGone() }
+ verifyEmptyFields()
+ snackbarSection { verify { isDisplaying(ComposerSnackbar.DraftOutOfSync) } }
+ }
+ }
+
+ private fun verifyLoadedCachedData() {
+ composerRobot {
+ verifyPrefilledFields(
+ toRecipientChip = expectedToChip,
+ subject = expectedSubject,
+ messageBody = expectedMessageBody
+ )
+
+ topAppBarSection { tapCloseButton() }
+ }
+
+ mailboxRobot { listSection { clickMessageByPosition(0) } }
+
+ composerRobot {
+ fullscreenLoaderSection { waitUntilGone() }
+
+ verifyPrefilledFields(
+ toRecipientChip = expectedToChip,
+ subject = expectedSubject,
+ messageBody = expectedMessageBody
+ )
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsHappyPathTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsHappyPathTests.kt
new file mode 100644
index 0000000000..c1394c3d1e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsHappyPathTests.kt
@@ -0,0 +1,321 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.drafts
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority
+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.withPriority
+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.common.section.fullscreenLoaderSection
+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.section.messageBodySection
+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
+import ch.protonmail.android.uitest.robot.composer.section.recipients.verify
+import ch.protonmail.android.uitest.robot.composer.section.senderSection
+import ch.protonmail.android.uitest.robot.composer.section.subjectSection
+import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.composer.section.verify
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class OpenExistingDraftsHappyPathTests :
+ MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara),
+ OpenExistingDraftsTest {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val expectedToChip = RecipientChipEntry(
+ index = 0,
+ text = "aa@bb.cc",
+ hasDeleteIcon = true,
+ state = RecipientChipValidationState.Valid
+ )
+
+ private val expectedCcChip = RecipientChipEntry(
+ index = 0,
+ text = "dd@ee.ff",
+ state = RecipientChipValidationState.Valid
+ )
+
+ private val expectedBccChip = RecipientChipEntry(
+ index = 0,
+ text = "gg@hh.ii",
+ state = RecipientChipValidationState.Valid
+ )
+
+ private val expectedSubject = "Test subject"
+ private val expectedSubjectPlaceholder = "(No Subject)"
+ private val expectedMessageBody = "Some text"
+ private val expectedAliasAddress = "shortcapybara@pm.me.proton.black"
+
+ @Test
+ @TestId("212662", "212664")
+ fun openDraftWithPrefilledToRecipientAndOtherFields() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212662.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212662.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.EditDraft())
+ }
+
+ composerRobot {
+ toRecipientSection { verify { hasRecipientChips(expectedToChip) } }
+ ccRecipientSection { verify { isHidden() } }
+ bccRecipientSection { verify { isHidden() } }
+ toRecipientSection { expandCcAndBccFields() }
+ ccRecipientSection { verify { isEmptyField() } }
+ bccRecipientSection { verify { isEmptyField() } }
+ subjectSection { verify { hasSubject(expectedSubject) } }
+ messageBodySection { verify { hasText(expectedMessageBody) } }
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("212663")
+ fun openDraftWithAllPrefilledFields() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212663.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212663.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.EditDraft())
+ }
+
+ composerRobot {
+ verifyPrefilledFields(
+ toRecipientChip = expectedToChip,
+ ccRecipientChip = expectedCcChip,
+ bccRecipientChip = expectedBccChip,
+ subject = expectedSubject,
+ messageBody = expectedMessageBody
+ )
+ }
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("212663/2")
+ fun openDraftWithAllPrefilledFieldsInMessageMode() {
+ 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_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=8&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_212663.json"
+ withStatusCode 200 withPriority MockPriority.Highest,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212663.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.EditDraft())
+ }
+
+ composerRobot {
+ verifyPrefilledFields(
+ toRecipientChip = expectedToChip,
+ ccRecipientChip = expectedCcChip,
+ bccRecipientChip = expectedBccChip,
+ subject = expectedSubject,
+ messageBody = expectedMessageBody
+ )
+ }
+ }
+
+ @Test
+ @TestId("212665")
+ fun openDraftWithoutSubjectAndMessageBody() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212665.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212665.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.EditDraft())
+ }
+
+ composerRobot {
+ verifyPrefilledFields(toRecipientChip = expectedToChip, subject = expectedSubjectPlaceholder)
+ }
+ }
+
+ @Test
+ @TestId("212666")
+ fun openingDraftsAlwaysFetchesRemoteContentFirst() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212666.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212666.json"
+ withStatusCode 200 matchWildcards true serveOnce true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212666_2.json"
+ withStatusCode 200 matchWildcards true
+ )
+ }
+
+ val expectedUpdatedToChip = expectedToChip.copy(text = "aa@bb2.cc")
+ val expectedUpdatedSubject = "Test subject 2"
+ val expectedUpdatedMessageBody = "Some text 2"
+
+ navigator {
+ navigateTo(Destination.EditDraft())
+ }
+
+ composerRobot {
+ verifyPrefilledFields(
+ toRecipientChip = expectedToChip,
+ subject = expectedSubject,
+ messageBody = expectedMessageBody
+ )
+
+ topAppBarSection { tapCloseButton() }
+ }
+
+ mailboxRobot {
+ listSection { clickMessageByPosition(0) }
+ }
+
+ composerRobot {
+ fullscreenLoaderSection { waitUntilGone() }
+
+ verifyPrefilledFields(
+ toRecipientChip = expectedUpdatedToChip,
+ subject = expectedUpdatedSubject,
+ messageBody = expectedUpdatedMessageBody
+ )
+ }
+ }
+
+ @Test
+ @TestId("212675")
+ fun openingDraftPreservesSenderAddress() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_212675.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/mail/v4/messages/*")
+ respondWith "/mail/v4/messages/message-id/message-id_212675.json"
+ withStatusCode 200 matchWildcards true serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.EditDraft())
+ }
+
+ composerRobot {
+ senderSection { verify { hasValue(expectedAliasAddress) } }
+ toRecipientSection { verify { hasRecipientChips(expectedToChip) } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsTest.kt
new file mode 100644
index 0000000000..af3fad2ed2
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsTest.kt
@@ -0,0 +1,68 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.drafts
+
+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.section.messageBodySection
+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
+import ch.protonmail.android.uitest.robot.composer.section.recipients.verify
+import ch.protonmail.android.uitest.robot.composer.section.subjectSection
+import ch.protonmail.android.uitest.robot.composer.section.verify
+
+internal interface OpenExistingDraftsTest {
+
+ fun ComposerRobot.verifyPrefilledFields(
+ toRecipientChip: RecipientChipEntry,
+ ccRecipientChip: RecipientChipEntry? = null,
+ bccRecipientChip: RecipientChipEntry? = null,
+ subject: String,
+ messageBody: String? = null
+ ) {
+ toRecipientSection {
+ verify { hasRecipientChips(toRecipientChip) }
+ }
+
+ 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() } }
+ }
+
+ fun ComposerRobot.verifyEmptyFields() {
+ toRecipientSection {
+ verify { isEmptyField() }
+ expandCcAndBccFields()
+ }
+ ccRecipientSection { verify { isEmptyField() } }
+ bccRecipientSection { verify { isEmptyField() } }
+ subjectSection { verify { hasEmptySubject() } }
+ messageBodySection { verify { hasPlaceholderText() } }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/ConversationModeAppendItemsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/ConversationModeAppendItemsTests.kt
new file mode 100644
index 0000000000..5cc4c30b6f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/ConversationModeAppendItemsTests.kt
@@ -0,0 +1,123 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.errors.append
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry
+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 org.junit.Ignore
+import org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+@Ignore("To be enabled again when MAILANDR-1162 is addressed.")
+@UninstallModules(ServerProofModule::class)
+internal class ConversationModeAppendItemsTests : MockedNetworkTest(), MailboxAppendItemsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ override val lastExpectedMailboxItem = MailboxListItemEntry(
+ index = 101,
+ avatarInitial = AvatarInitial.WithText("P"),
+ participants = listOf(ParticipantEntry.WithParticipant("Proton", isProton = true)),
+ subject = "Last Element!",
+ date = "May 7, 2023"
+ )
+
+ @Test
+ @TestId("189113")
+ @Suppress("MaxLineLength")
+ fun checkAppendErrorAndRetryInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_189113_1.json"
+ withStatusCode 200 serveOnce true,
+ get("/mail/v4/conversations?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1683964808&EndID=-ca1Hsn5gJ5pVXKT683Jks9DF_HMYnJ320IAdwRamIM8Y-qmce6sHmX9ybG692_KPk89lEuTp5OU0iAFzwF2zA%3D%3D")
+ respondWith "/mail/v4/conversations/conversations_189113_2.json"
+ withStatusCode 200 serveOnce true,
+ get("/mail/v4/conversations?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1683532886&EndID=yFFVROGaGAfA0O4rOchW_1oF_-Giys_QfSaRS69zTeWOuyQmwx_SESSDZlVp67N76pBde92SyQ-cMDlA_71T5w%3D%3D")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 withPriority MockPriority.Highest withNetworkDelay 2000 serveOnce true,
+ get("/mail/v4/conversations?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1683532886&EndID=yFFVROGaGAfA0O4rOchW_1oF_-Giys_QfSaRS69zTeWOuyQmwx_SESSDZlVp67N76pBde92SyQ-cMDlA_71T5w%3D%3D")
+ respondWith "/mail/v4/conversations/conversations_189113_3.json"
+ withStatusCode 200 withNetworkDelay 2000
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyAppendAdditionalItemsErrorAndRetry()
+ }
+
+ @Test
+ @TestId("189113/2", "189158")
+ @Suppress("MaxLineLength")
+ fun checkAppendItemsInConversationMode() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_189113_1.json"
+ withStatusCode 200 serveOnce true,
+ get("/mail/v4/conversations?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1683964808&EndID=-ca1Hsn5gJ5pVXKT683Jks9DF_HMYnJ320IAdwRamIM8Y-qmce6sHmX9ybG692_KPk89lEuTp5OU0iAFzwF2zA%3D%3D")
+ respondWith "/mail/v4/conversations/conversations_189113_2.json"
+ withStatusCode 200 serveOnce true,
+ get("/mail/v4/conversations?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1683532886&EndID=yFFVROGaGAfA0O4rOchW_1oF_-Giys_QfSaRS69zTeWOuyQmwx_SESSDZlVp67N76pBde92SyQ-cMDlA_71T5w%3D%3D")
+ respondWith "/mail/v4/conversations/conversations_189113_3.json"
+ withStatusCode 200 withNetworkDelay 2000
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyAppendAdditionalItems()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/MailboxAppendItemsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/MailboxAppendItemsTests.kt
new file mode 100644
index 0000000000..e867c47ccb
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/MailboxAppendItemsTests.kt
@@ -0,0 +1,68 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.errors.append
+
+import ch.protonmail.android.uitest.e2e.mailbox.errors.append.ScrollThreshold.FirstScrollThreshold
+import ch.protonmail.android.uitest.e2e.mailbox.errors.append.ScrollThreshold.SecondScrollThreshold
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.appendErrorSection
+import ch.protonmail.android.uitest.robot.mailbox.section.appendLoadingSection
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.section.verify
+
+internal interface MailboxAppendItemsTests {
+
+ val lastExpectedMailboxItem: MailboxListItemEntry
+
+ fun verifyAppendAdditionalItems() = verifyAppendItemsLoading(expectError = false)
+
+ fun verifyAppendAdditionalItemsErrorAndRetry() = verifyAppendItemsLoading(expectError = true)
+
+ private fun verifyAppendItemsLoading(expectError: Boolean) {
+ mailboxRobot {
+ listSection {
+ scrollToItemAtIndex(FirstScrollThreshold)
+ scrollToItemAtIndex(SecondScrollThreshold)
+ }
+
+ appendLoadingSection { verify { isShown() } }
+
+ if (expectError) {
+ appendErrorSection {
+ verify { isShown() }
+ tapRetryButton()
+ verify { isHidden() }
+ }
+ }
+
+ appendLoadingSection { verify { isHidden() } }
+
+ listSection {
+ verify { listItemsAreShown(lastExpectedMailboxItem) }
+ }
+ }
+ }
+}
+
+private object ScrollThreshold {
+
+ const val FirstScrollThreshold = 75
+ const val SecondScrollThreshold = 99
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/MessageModeAppendItemsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/MessageModeAppendItemsTests.kt
new file mode 100644
index 0000000000..7f36c72f08
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/MessageModeAppendItemsTests.kt
@@ -0,0 +1,123 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.errors.append
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry
+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 org.junit.Ignore
+import org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+@Ignore("To be enabled again when MAILANDR-1162 is addressed.")
+@UninstallModules(ServerProofModule::class)
+internal class MessageModeAppendItemsTests : MockedNetworkTest(), MailboxAppendItemsTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ override val lastExpectedMailboxItem = MailboxListItemEntry(
+ index = 101,
+ avatarInitial = AvatarInitial.WithText("P"),
+ participants = listOf(ParticipantEntry.WithParticipant("Proton", isProton = true)),
+ subject = "Last Element!",
+ date = "Mar 6, 2023"
+ )
+
+ @Test
+ @TestId("189114")
+ @Suppress("MaxLineLength")
+ fun checkAppendErrorAndRetryInMessageMode() {
+ 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?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_189114_1.json"
+ withStatusCode 200 serveOnce true,
+ get("/mail/v4/messages?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1687181980&EndID=Ipolvuzgp9N-3XwngHmiQZ9fDZV3CUSv65Pi3SjL75I_-mhS4sxdT2qNo5-GEoLuFuzInClxbq3tKM9MydlQzQ%3D%3D")
+ respondWith "/mail/v4/messages/messages_189114_2.json"
+ withStatusCode 200 serveOnce true,
+ get("/mail/v4/messages?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1678107386&EndID=Q0bXXG7rlW34PI8sKXbIliVZ2ybuoIefe933RlbTZYrjpl1nsWG7FKTAW7s4nnskarFkbvpPVOESe0omarcHsQ%3D%3D")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 withPriority MockPriority.Highest withNetworkDelay 2000 serveOnce true,
+ get("/mail/v4/messages?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1678107386&EndID=Q0bXXG7rlW34PI8sKXbIliVZ2ybuoIefe933RlbTZYrjpl1nsWG7FKTAW7s4nnskarFkbvpPVOESe0omarcHsQ%3D%3D")
+ respondWith "/mail/v4/messages/messages_189114_3.json"
+ withStatusCode 200 withNetworkDelay 2000
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyAppendAdditionalItemsErrorAndRetry()
+ }
+
+ @Test
+ @TestId("189114/2", "189159")
+ @Suppress("MaxLineLength")
+ fun checkAppendItemsInMessageMode() {
+ 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?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1")
+ respondWith "/mail/v4/messages/messages_189114_1.json"
+ withStatusCode 200 serveOnce true,
+ get("/mail/v4/messages?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1687181980&EndID=Ipolvuzgp9N-3XwngHmiQZ9fDZV3CUSv65Pi3SjL75I_-mhS4sxdT2qNo5-GEoLuFuzInClxbq3tKM9MydlQzQ%3D%3D")
+ respondWith "/mail/v4/messages/messages_189114_2.json"
+ withStatusCode 200 serveOnce true,
+ get("/mail/v4/messages?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1678107386&EndID=Q0bXXG7rlW34PI8sKXbIliVZ2ybuoIefe933RlbTZYrjpl1nsWG7FKTAW7s4nnskarFkbvpPVOESe0omarcHsQ%3D%3D")
+ respondWith "/mail/v4/messages/messages_189114_3.json"
+ withStatusCode 200 withNetworkDelay 2000
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyAppendAdditionalItems()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/ConversationModeMailboxPullToRefreshErrorTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/ConversationModeMailboxPullToRefreshErrorTests.kt
new file mode 100644
index 0000000000..b14e6c6b87
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/ConversationModeMailboxPullToRefreshErrorTests.kt
@@ -0,0 +1,198 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.errors.pulltorefresh
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay
+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.network.mockNetworkDispatcher
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class ConversationModeMailboxPullToRefreshErrorTests :
+ MockedNetworkTest(), MailboxPullToRefreshErrorTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("188892")
+ fun checkConversationsLoadingErrorToError() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/conversations")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 ignoreQueryParams true withNetworkDelay 2000 serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyErrorToError()
+ }
+
+ @Test
+ @TestId("188893")
+ fun checkConversationsLoadingContentToError() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/conversations")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 ignoreQueryParams true withNetworkDelay 2000 serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyContentToError()
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("188894")
+ fun checkConversationsLoadingErrorToContent() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true withNetworkDelay 2000 serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyErrorToContent()
+ }
+
+ @Test
+ @TestId("188895")
+ fun checkConversationsLoadingEmptyToError() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/conversations")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 ignoreQueryParams true withNetworkDelay 2000 serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyEmptyToError()
+ }
+
+ @Test
+ @TestId("188896")
+ fun checkConversationsLoadingErrorToEmpty() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true withNetworkDelay 2000 serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyErrorToEmpty()
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("188897")
+ fun checkConversationsLoadingEmptyToContent() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_empty.json"
+ withStatusCode 200 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true withNetworkDelay 2000 serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyEmptyToContent()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/MailboxPullToRefreshErrorTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/MailboxPullToRefreshErrorTests.kt
new file mode 100644
index 0000000000..22c11b8314
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/MailboxPullToRefreshErrorTests.kt
@@ -0,0 +1,127 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.errors.pulltorefresh
+
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry
+import ch.protonmail.android.uitest.robot.common.section.snackbarSection
+import ch.protonmail.android.uitest.robot.common.section.verify
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.model.snackbar.MailboxSnackbar
+import ch.protonmail.android.uitest.robot.mailbox.section.emptyListSection
+import ch.protonmail.android.uitest.robot.mailbox.section.fullScreenErrorSection
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.section.verify
+
+internal interface MailboxPullToRefreshErrorTests {
+
+ private val baseItem: MailboxListItemEntry
+ get() = MailboxListItemEntry(
+ index = 0,
+ avatarInitial = AvatarInitial.WithText("M"),
+ participants = listOf(ParticipantEntry.WithParticipant("mobileappsuitesting2")),
+ subject = "Test message",
+ date = "Mar 6, 2023"
+ )
+
+ fun verifyEmptyToContent() {
+ mailboxRobot {
+ emptyListSection {
+ verify { isShown() }
+
+ pullDownToRefresh()
+ }
+
+ listSection {
+ verify { listItemsAreShown(baseItem) }
+ }
+ }
+ }
+
+ fun verifyErrorToEmpty() {
+ mailboxRobot {
+ fullScreenErrorSection {
+ verify { isShown() }
+
+ pullDownToRefresh()
+ }
+
+ emptyListSection {
+ verify { isShown() }
+ }
+ }
+ }
+
+ fun verifyEmptyToError() {
+ mailboxRobot {
+ emptyListSection {
+ verify { isShown() }
+
+ pullDownToRefresh()
+ }
+
+ fullScreenErrorSection {
+ verify { isShown() }
+ }
+ }
+ }
+
+ fun verifyErrorToContent() {
+ mailboxRobot {
+ fullScreenErrorSection {
+ verify { isShown() }
+
+ pullDownToRefresh()
+ }
+
+ listSection {
+ verify { listItemsAreShown(baseItem) }
+ }
+ }
+ }
+
+ fun verifyContentToError() {
+ mailboxRobot {
+ listSection {
+ verify { listItemsAreShown(baseItem) }
+
+ pullDownToRefresh()
+ }
+
+ snackbarSection {
+ verify { isDisplaying(MailboxSnackbar.FailedToLoadNewItems) }
+ }
+
+ listSection {
+ verify { listItemsAreShown(baseItem) }
+ }
+ }
+ }
+
+ fun verifyErrorToError() {
+ mailboxRobot {
+ fullScreenErrorSection {
+ verify { isShown() }
+ pullDownToRefresh()
+ verify { isShown() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/MessageModeMailboxPullToRefreshErrorTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/MessageModeMailboxPullToRefreshErrorTests.kt
new file mode 100644
index 0000000000..8772aee429
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/MessageModeMailboxPullToRefreshErrorTests.kt
@@ -0,0 +1,198 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.errors.pulltorefresh
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay
+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.network.mockNetworkDispatcher
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class MessageModeMailboxPullToRefreshErrorTests :
+ MockedNetworkTest(), MailboxPullToRefreshErrorTests {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("188899")
+ fun checkMessagesLoadingErrorToError() {
+ 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 "/global/errors/error_mock.json"
+ withStatusCode 503 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/messages")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 ignoreQueryParams true withNetworkDelay 2000 serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyErrorToError()
+ }
+
+ @Test
+ @TestId("188900")
+ fun checkMessagesLoadingContentToError() {
+ 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_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/messages")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 ignoreQueryParams true withNetworkDelay 2000 serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyContentToError()
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("188901")
+ fun checkMessagesLoadingErrorToContent() {
+ 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 "/global/errors/error_mock.json"
+ withStatusCode 503 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true withNetworkDelay 2000 serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyErrorToContent()
+ }
+
+ @Test
+ @TestId("188902")
+ fun checkMessagesLoadingEmptyToError() {
+ 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_empty.json"
+ withStatusCode 200 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/messages")
+ respondWith "/global/errors/error_mock.json"
+ withStatusCode 503 ignoreQueryParams true withNetworkDelay 2000 serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyEmptyToError()
+ }
+
+ @Test
+ @TestId("188903")
+ fun checkMessagesLoadingErrorToEmpty() {
+ 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 "/global/errors/error_mock.json"
+ withStatusCode 503 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true withNetworkDelay 2000 serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyErrorToEmpty()
+ }
+
+ @Test
+ @SmokeTest
+ @TestId("188904")
+ fun checkMessagesLoadingEmptyToContent() {
+ 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_empty.json"
+ withStatusCode 200 ignoreQueryParams true serveOnce true,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true withNetworkDelay 2000 serveOnce true
+ )
+ }
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ verifyEmptyToContent()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeBottomBarActionsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeBottomBarActionsTests.kt
new file mode 100644
index 0000000000..24bc495fc5
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeBottomBarActionsTests.kt
@@ -0,0 +1,191 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.selection
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.models.bottombar.BottomBarActionEntry
+import ch.protonmail.android.uitest.robot.bottombar.bottomBarSection
+import ch.protonmail.android.uitest.robot.bottombar.verify
+import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class SelectionModeBottomBarActionsTests : MockedNetworkTest(
+ loginType = LoginTestUserTypes.Paid.FancyCapybara
+) {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("216624")
+ fun testBottomBarInboxActionsOnUnreadMessage() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_216624.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ navigator { navigateTo(Destination.Inbox) }
+
+ mailboxRobot {
+ clickAndMatchSelectionModeBottomBarActions(BottomBarActionEntry.Defaults.actionsOnUnreadItem)
+ }
+ }
+
+ @Test
+ @TestId("216624/2")
+ fun testBottomBarInboxActionsOnReadMessage() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_216624_2.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ navigator { navigateTo(Destination.Inbox) }
+
+ mailboxRobot {
+ clickAndMatchSelectionModeBottomBarActions(BottomBarActionEntry.Defaults.actionsOnReadItem)
+ }
+ }
+
+ @Test
+ @TestId("216625")
+ fun testBottomBarInboxActionsOnUnreadTrashedMessage() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=3&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_216625.json"
+ withStatusCode 200
+ )
+ }
+
+ navigator { navigateTo(Destination.Trash) }
+
+ mailboxRobot {
+ clickAndMatchSelectionModeBottomBarActions(BottomBarActionEntry.Defaults.actionsOnTrashedUnreadItem)
+ }
+ }
+
+ @Test
+ @TestId("216625/2")
+ fun testBottomBarInboxActionsOnReadTrashedMessage() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=3&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_216625_2.json"
+ withStatusCode 200
+ )
+ }
+
+ navigator { navigateTo(Destination.Trash) }
+
+ mailboxRobot {
+ clickAndMatchSelectionModeBottomBarActions(BottomBarActionEntry.Defaults.actionsOnTrashedReadItem)
+ }
+ }
+
+ @Test
+ @TestId("216625/3")
+ fun testBottomBarInboxActionsOnUnreadSpamMessage() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=4&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_216625_3.json"
+ withStatusCode 200
+ )
+ }
+
+ navigator { navigateTo(Destination.Spam) }
+
+ mailboxRobot {
+ clickAndMatchSelectionModeBottomBarActions(BottomBarActionEntry.Defaults.actionsOnSpamUnreadItem)
+ }
+ }
+
+ @Test
+ @TestId("216625/4")
+ fun testBottomBarInboxActionsOnReadSpamMessage() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=4&Sort=Time&Desc=1")
+ respondWith "/mail/v4/conversations/conversations_216625_4.json"
+ withStatusCode 200
+ )
+ }
+
+ navigator { navigateTo(Destination.Spam) }
+
+ mailboxRobot {
+ clickAndMatchSelectionModeBottomBarActions(BottomBarActionEntry.Defaults.actionsOnSpamReadItem)
+ }
+ }
+}
+
+private fun MailboxRobot.clickAndMatchSelectionModeBottomBarActions(expectedActions: Array) {
+ listSection { selectItemsAt(0) }
+
+ bottomBarSection {
+ verify { hasActions(*expectedActions) }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeBottomBarTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeBottomBarTests.kt
new file mode 100644
index 0000000000..272936409a
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeBottomBarTests.kt
@@ -0,0 +1,162 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.selection
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.bottombar.bottomBarSection
+import ch.protonmail.android.uitest.robot.bottombar.verify
+import ch.protonmail.android.uitest.robot.helpers.deviceRobot
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class SelectionModeBottomBarTests : MockedNetworkTest(
+ loginType = LoginTestUserTypes.Paid.FancyCapybara
+) {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("216621")
+ fun testBottomBarDisplayed() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_216621.json"
+ withStatusCode 200 ignoreQueryParams true,
+ )
+ }
+
+ navigator { navigateTo(Destination.Inbox) }
+
+ mailboxRobot {
+ listSection { selectItemsAt(0) }
+ bottomBarSection { verify { isShown() } }
+ }
+ }
+
+ @Test
+ @TestId("216621/2")
+ fun testBottomBarDisplayedInMessageMode() {
+ 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_216621.json"
+ withStatusCode 200 ignoreQueryParams true,
+ )
+ }
+
+ navigator { navigateTo(Destination.Inbox) }
+
+ mailboxRobot {
+ listSection { selectItemsAt(0) }
+ bottomBarSection { verify { isShown() } }
+ }
+ }
+
+ @Test
+ @TestId("216621/2, 216622")
+ fun testBottomBarDismissalWhenBackButtonIsPressed() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_216621.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ navigator { navigateTo(Destination.Inbox) }
+
+ mailboxRobot {
+ listSection { selectItemsAt(0) }
+ bottomBarSection {
+ verify { isShown() }
+ }
+
+ deviceRobot { pressBack() }
+
+ bottomBarSection {
+ verify { isNotShown() }
+ }
+
+ verify { isShown() }
+ }
+ }
+
+ @Test
+ @TestId("216621/3, 216623")
+ fun testBottomBarDismissalWhenSelectionModeIsExited() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_216621.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ navigator { navigateTo(Destination.Inbox) }
+
+ mailboxRobot {
+ listSection { selectItemsAt(0) }
+ bottomBarSection {
+ verify { isShown() }
+ }
+
+ listSection { unselectItemsAtPosition(0) }
+ bottomBarSection {
+ verify { isNotShown() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeMainTests.kt
new file mode 100644
index 0000000000..344bb147ad
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeMainTests.kt
@@ -0,0 +1,215 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.mailbox.selection
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.models.mailbox.MailboxType
+import ch.protonmail.android.uitest.robot.bottombar.bottomBarSection
+import ch.protonmail.android.uitest.robot.bottombar.verify
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.mailbox.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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class SelectionModeMainTests : MockedNetworkTest(
+ loginType = LoginTestUserTypes.Paid.FancyCapybara
+) {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Before
+ fun prepareTests() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json"
+ withStatusCode 200,
+ get("/mail/v4/conversations")
+ respondWith "/mail/v4/conversations/conversations_215427.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+
+ navigator { navigateTo(Destination.Inbox) }
+ }
+
+ @Test
+ @TestId("254596")
+ fun backButtonDismissesSelectionMode() {
+ val selectedItemPosition = 0
+
+ mailboxRobot {
+ listSection {
+ selectItemsAt(selectedItemPosition)
+ verify { selectedItemAtPosition(selectedItemPosition) }
+ }
+
+ bottomBarSection { verify { isShown() } }
+
+ topAppBarSection {
+ verify { isInSelectionMode(numSelected = 1) }
+
+ tapExitSelectionMode()
+ verify { isMailbox(MailboxType.Inbox) }
+ }
+
+ bottomBarSection { verify { isNotShown() } }
+ }
+ }
+
+ @Test
+ @TestId("215426")
+ fun testItemIsSelectedWithLongPress() {
+ val selectedItemPosition = 0
+
+ mailboxRobot {
+ listSection {
+ longPressItemAtPosition(selectedItemPosition)
+ verify { selectedItemAtPosition(selectedItemPosition) }
+ }
+
+ topAppBarSection {
+ verify { isInSelectionMode(numSelected = 1) }
+ }
+
+ bottomBarSection { verify { isShown() } }
+ }
+ }
+
+ @Test
+ @TestId("215427", "215428", "215430")
+ fun testMultipleSelectedItems() {
+ val selectedItemPosition = 0
+ val secondSelectedItemPosition = 1
+ val unselectedItemPosition = 2
+ val secondUnselectedItemPosition = 3
+
+ mailboxRobot {
+ listSection {
+ selectItemsAt(selectedItemPosition, secondSelectedItemPosition)
+
+ verify {
+ selectedItemAtPosition(selectedItemPosition)
+ selectedItemAtPosition(secondSelectedItemPosition)
+ unSelectedItemAtPosition(unselectedItemPosition)
+ unSelectedItemAtPosition(secondUnselectedItemPosition)
+ }
+ }
+
+ topAppBarSection {
+ verify { isInSelectionMode(numSelected = 2) }
+ }
+
+ bottomBarSection { verify { isShown() } }
+ }
+ }
+
+ @Test
+ @TestId("215431")
+ fun testSelectionCountIncreases() {
+ val selectedItemPosition = 0
+ val secondSelectedItemPosition = 1
+
+ mailboxRobot {
+ listSection {
+ selectItemsAt(selectedItemPosition)
+ }
+
+ topAppBarSection {
+ verify { isInSelectionMode(numSelected = 1) }
+ }
+
+ bottomBarSection { verify { isShown() } }
+
+ listSection {
+ selectItemsAt(secondSelectedItemPosition)
+ }
+
+ topAppBarSection {
+ verify { isInSelectionMode(numSelected = 2) }
+ }
+
+ bottomBarSection { verify { isShown() } }
+ }
+ }
+
+ @Test
+ @TestId("215429", "215432", "215433")
+ fun testSelectionCountDecreasesAndExitSelectionMode() {
+ val selectedItemPosition = 0
+ val secondSelectedItemPosition = 1
+
+ mailboxRobot {
+ listSection {
+ selectItemsAt(selectedItemPosition, secondSelectedItemPosition)
+ }
+
+ topAppBarSection {
+ verify { isInSelectionMode(numSelected = 2) }
+ }
+
+ bottomBarSection { verify { isShown() } }
+
+ listSection {
+ unselectItemsAtPosition(secondSelectedItemPosition)
+ verify { unSelectedItemAtPosition(secondSelectedItemPosition) }
+ }
+
+ topAppBarSection {
+ verify { isInSelectionMode(numSelected = 1) }
+ }
+
+ listSection {
+ unselectItemsAtPosition(selectedItemPosition)
+ verify { unSelectedItemAtPosition(selectedItemPosition) }
+ }
+
+ topAppBarSection {
+ verify { isMailbox(MailboxType.Inbox) }
+ }
+
+ bottomBarSection { verify { isNotShown() } }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarMenuFoldersTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarMenuFoldersTests.kt
new file mode 100644
index 0000000000..edc96b0655
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarMenuFoldersTests.kt
@@ -0,0 +1,158 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.menu
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.network.mockNetworkDispatcher
+import ch.protonmail.android.uitest.models.folders.SidebarCustomItemEntry
+import ch.protonmail.android.uitest.models.folders.Tint
+import ch.protonmail.android.uitest.robot.menu.menuRobot
+import ch.protonmail.android.uitest.robot.menu.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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class SidebarMenuFoldersTests : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("68718")
+ fun checkShortHexAndStandardColorFolderAreDisplayedInSidebarMenu() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultCustomFolders = false,
+ useDefaultMailSettings = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_68718.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_68718.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ val expectedFolders = arrayOf(
+ SidebarCustomItemEntry(index = 0, name = "Shorthand Hex Folder", iconTint = Tint.WithColor.Bridge),
+ SidebarCustomItemEntry(index = 1, name = "Standard Folder", iconTint = Tint.WithColor.PurpleBase)
+ )
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ menuRobot {
+ openSidebarMenu()
+
+ verify { customFoldersAreDisplayed(*expectedFolders) }
+ }
+ }
+
+ @Test
+ @TestId("79096")
+ fun checkFoldersColorWhenSettingIsOffWithNoParentInheritingInSidebarMenu() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultCustomFolders = false,
+ useDefaultMailSettings = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_79096.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_79096.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ val expectedFolders = arrayOf(
+ SidebarCustomItemEntry(index = 0, name = "Shorthand Hex Folder", iconTint = Tint.NoColor),
+ SidebarCustomItemEntry(index = 1, name = "Standard Folder", iconTint = Tint.NoColor)
+ )
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ menuRobot {
+ openSidebarMenu()
+ verify { customFoldersAreDisplayed(*expectedFolders) }
+ }
+ }
+
+ @Test
+ @TestId("79097")
+ fun checkFoldersColorWhenSettingIsOffWithParentInheritingInSidebarMenu() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher(
+ useDefaultCustomFolders = false,
+ useDefaultMailSettings = false
+ ) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_79097.json"
+ withStatusCode 200,
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_79097.json"
+ withStatusCode 200,
+ get("/mail/v4/messages")
+ respondWith "/mail/v4/messages/messages_empty.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ val expectedFolders = arrayOf(
+ SidebarCustomItemEntry(index = 0, name = "Shorthand Hex Folder", iconTint = Tint.NoColor),
+ SidebarCustomItemEntry(index = 1, name = "Standard Folder", iconTint = Tint.NoColor)
+ )
+
+ navigator {
+ navigateTo(Destination.Inbox)
+ }
+
+ menuRobot {
+ openSidebarMenu()
+ verify { customFoldersAreDisplayed(*expectedFolders) }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarReportBugFlowTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarReportBugFlowTests.kt
new file mode 100644
index 0000000000..2f7d8e30de
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarReportBugFlowTests.kt
@@ -0,0 +1,72 @@
+package ch.protonmail.android.uitest.e2e.menu
+
+import androidx.test.core.app.ApplicationProvider
+import ch.protonmail.android.MainActivity
+import ch.protonmail.android.initializer.MainInitializer
+import ch.protonmail.android.test.annotations.suite.CoreLibraryTest
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+import ch.protonmail.android.uitest.di.LocalhostApi
+import ch.protonmail.android.uitest.di.LocalhostApiModule
+import ch.protonmail.android.uitest.helpers.core.navigation.Destination
+import ch.protonmail.android.uitest.helpers.core.navigation.navigator
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.verify
+import ch.protonmail.android.uitest.robot.menu.menuRobot
+import ch.protonmail.android.uitest.rule.GrantNotificationsPermissionRule
+import ch.protonmail.android.uitest.rule.MockOnboardingRuntimeRule
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.UninstallModules
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import me.proton.core.report.test.MinimalReportInternalTests
+import me.proton.core.test.rule.extension.protonAndroidComposeRule
+import org.junit.Before
+import org.junit.Rule
+import javax.inject.Inject
+
+@CoreLibraryTest
+@HiltAndroidTest
+@UninstallModules(LocalhostApiModule::class)
+internal class SidebarReportBugFlowTests : MinimalReportInternalTests {
+
+ @JvmField
+ @BindValue
+ @LocalhostApi
+ val localhostApi = false
+
+ @Inject
+ lateinit var mockOnboardingRuntimeRule: MockOnboardingRuntimeRule
+
+ @get:Rule
+ val protonTestRule = protonAndroidComposeRule(
+ composeTestRule = ComposeTestRuleHolder.createAndGetComposeRule(),
+ logoutBefore = false,
+ fusionEnabled = false,
+ additionalRules = linkedSetOf(GrantNotificationsPermissionRule()),
+ afterHilt = { mainInitializer() }
+ )
+
+ @Before
+ fun setup() {
+ mockOnboardingRuntimeRule(false)
+ }
+
+ override fun startReport() {
+ navigator { navigateTo(Destination.Inbox, performLoginViaUI = false) }
+
+ menuRobot {
+ openSidebarMenu()
+ openReportBugs()
+ }
+ }
+
+ override fun verifyAfter() {
+ mailboxRobot { verify { isShown() } }
+ }
+
+ private fun mainInitializer() = runBlocking {
+ withContext(Dispatchers.Main) { MainInitializer.init(ApplicationProvider.getApplicationContext()) }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarSubscriptionFlowTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarSubscriptionFlowTest.kt
new file mode 100644
index 0000000000..b911f75b6b
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarSubscriptionFlowTest.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2023 Proton AG
+ * This file is part of Proton 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.menu
+
+import androidx.test.core.app.ApplicationProvider
+import ch.protonmail.android.MainActivity
+import ch.protonmail.android.initializer.MainInitializer
+import ch.protonmail.android.test.annotations.suite.CoreLibraryTest
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+import ch.protonmail.android.uitest.di.LocalhostApi
+import ch.protonmail.android.uitest.di.LocalhostApiModule
+import ch.protonmail.android.uitest.helpers.core.navigation.Destination
+import ch.protonmail.android.uitest.helpers.core.navigation.navigator
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.verify
+import ch.protonmail.android.uitest.robot.menu.menuRobot
+import ch.protonmail.android.uitest.rule.GrantNotificationsPermissionRule
+import ch.protonmail.android.uitest.rule.MockOnboardingRuntimeRule
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.UninstallModules
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import me.proton.core.plan.test.MinimalSubscriptionTests
+import me.proton.core.plan.test.robot.SubscriptionRobot
+import me.proton.core.test.rule.extension.protonAndroidComposeRule
+import org.junit.Before
+import org.junit.Rule
+import javax.inject.Inject
+
+@CoreLibraryTest
+@HiltAndroidTest
+@UninstallModules(LocalhostApiModule::class)
+internal class SidebarSubscriptionFlowTest : MinimalSubscriptionTests() {
+
+ @JvmField
+ @BindValue
+ @LocalhostApi
+ val localhostApi = false
+
+ @Inject
+ lateinit var mockOnboardingRuntimeRule: MockOnboardingRuntimeRule
+
+ @get:Rule
+ val protonTestRule = protonAndroidComposeRule(
+ composeTestRule = ComposeTestRuleHolder.createAndGetComposeRule(),
+ logoutBefore = false,
+ fusionEnabled = false,
+ additionalRules = linkedSetOf(GrantNotificationsPermissionRule()),
+ afterHilt = { mainInitializer() }
+ )
+
+ @Before
+ fun setup() {
+ mockOnboardingRuntimeRule(false)
+ }
+
+ override fun startSubscription(): SubscriptionRobot {
+ navigator { navigateTo(Destination.Inbox, performLoginViaUI = false) }
+ mailboxRobot { verify { isShown() } }
+
+ menuRobot {
+ openSidebarMenu()
+ openSubscription()
+ }
+
+ return SubscriptionRobot
+ }
+
+ private fun mainInitializer() = runBlocking {
+ withContext(Dispatchers.Main) { MainInitializer.init(ApplicationProvider.getApplicationContext()) }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/onboarding/OnboardingMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/onboarding/OnboardingMainTests.kt
new file mode 100644
index 0000000000..0efbe70292
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/onboarding/OnboardingMainTests.kt
@@ -0,0 +1,80 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.onboarding
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+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.onboarding.onboardingRobot
+import ch.protonmail.android.uitest.robot.onboarding.section.topBarSection
+import ch.protonmail.android.uitest.robot.onboarding.section.bottomSection
+import ch.protonmail.android.uitest.robot.onboarding.section.middleSection
+import ch.protonmail.android.uitest.robot.onboarding.section.verify
+import ch.protonmail.android.uitest.robot.onboarding.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 org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+@UninstallModules(ServerProofModule::class)
+internal class OnboardingMainTests : MockedNetworkTest(
+ showOnboarding = true,
+ loginType = LoginTestUserTypes.Paid.FancyCapybara
+) {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ @Test
+ @TestId("256675")
+ fun checkOnboardingScreenShownAtFirstStartup() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher()
+
+ navigator {
+ navigateTo(Destination.Onboarding)
+ }
+
+ onboardingRobot {
+ verify { isShown() }
+
+ topBarSection {
+ verify { isCloseButtonShown() }
+ }
+
+ middleSection {
+ verify { isOnboardingImageShown() }
+ }
+
+ bottomSection {
+ verify { isBottomButtonShown() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/settings/SettingsFlowTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/settings/SettingsFlowTest.kt
new file mode 100644
index 0000000000..693955ffd6
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/settings/SettingsFlowTest.kt
@@ -0,0 +1,175 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.e2e.settings
+
+import ch.protonmail.android.di.ServerProofModule
+import ch.protonmail.android.networkmocks.mockwebserver.combineWith
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.MockedNetworkTest
+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.menu.MenuRobot
+import ch.protonmail.android.uitest.robot.settings.account.verify
+import ch.protonmail.android.uitest.robot.settings.swipeactions.verify
+import ch.protonmail.android.uitest.robot.settings.verify
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+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 org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@UninstallModules(ServerProofModule::class)
+@HiltAndroidTest
+internal class SettingsFlowTest : MockedNetworkTest() {
+
+ @JvmField
+ @BindValue
+ val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true)
+
+ private val menuRobot = MenuRobot()
+
+ @Before
+ fun setupDispatcher() {
+ mockWebServer.dispatcher combineWith mockNetworkDispatcher()
+ navigator { navigateTo(Destination.Inbox) }
+ }
+
+ @Test
+ fun openAccountSettings() {
+ menuRobot
+ .openSidebarMenu()
+ .openSettings()
+ .openUserAccountSettings()
+ .verify { accountSettingsScreenIsDisplayed() }
+ }
+
+ @Test
+ fun openConversationModeSetting() {
+ menuRobot
+ .openSidebarMenu()
+ .openSettings()
+ .openUserAccountSettings()
+ .verify { accountSettingsScreenIsDisplayed() }
+ .openConversationMode()
+ .verify { conversationModeToggleIsDisplayedAndEnabled() }
+ }
+
+ @Test
+ fun openSettingAndChangePreferredTheme() {
+ menuRobot
+ .openSidebarMenu()
+ .openSettings()
+ .openThemeSettings()
+ .selectSystemDefault()
+ .verify { defaultThemeSettingIsSelected() }
+ .selectDarkTheme()
+ .verify { darkThemeIsSelected() }
+ }
+
+ @Test
+ fun openSettingAndChangePreferredLanguage() {
+ val languageSettingsRobot = menuRobot
+ .openSidebarMenu()
+ .openSettings()
+ .openLanguageSettings()
+ .selectSystemDefault()
+ .verify { defaultLanguageIsSelected() }
+ .selectSpanish()
+ .verify {
+ spanishLanguageIsSelected()
+ appLanguageChangedToSpanish()
+ }
+ .selectBrazilianPortuguese()
+ .verify {
+ brazilianPortugueseLanguageIsSelected()
+ appLanguageChangedToPortuguese()
+ }
+
+ ComposeTestRuleHolder.rule.waitForIdle()
+
+ /*
+ * Once Brazilian was selected, we can't just use `selectSystemDefault` to go back to default language,
+ * since the values returned from `string.mail_settings_system_default` are still the default language ones
+ * while the app is now in Brazilian, which causes a failure as "System default" string is not found.
+ * The assumption is that this happens because the Instrumentation's context is not updated when changing lang
+ */
+ languageSettingsRobot
+ .selectSystemDefaultFromBrazilian()
+ .verify { defaultLanguageIsSelected() }
+ }
+
+ @Test
+ fun openPasswordManagementSettings() {
+ menuRobot
+ .openSidebarMenu()
+ .openSettings()
+ .openUserAccountSettings()
+ .openPasswordManagement()
+ .verify { passwordManagementElementsDisplayed() }
+ }
+
+ @Test
+ fun openSettingsAndChangeLeftSwipeAction() {
+ menuRobot
+ .openSidebarMenu()
+ .openSettings()
+ .openSwipeActions()
+ .openSwipeLeft()
+ .selectArchive()
+ .navigateUpToSwipeActions()
+ .verify { swipeLeft { isArchive() } }
+ }
+
+ @Test
+ fun openSettingsAndChangeRightSwipeAction() {
+ menuRobot
+ .openSidebarMenu()
+ .openSettings()
+ .openSwipeActions()
+ .openSwipeRight()
+ .selectMarkRead()
+ .navigateUpToSwipeActions()
+ .verify { swipeRight { isMarkRead() } }
+ }
+
+ @Test
+ fun openSettingsAndChangeCombinedContactsSetting() {
+ menuRobot
+ .openSidebarMenu()
+ .openSettings()
+ .openCombinedContactsSettings()
+ .turnOnCombinedContacts()
+ .verify { combinedContactsSettingIsToggled() }
+ }
+
+ @Test
+ fun openSettingsAndChangeAlternativeRoutingSetting() {
+ menuRobot
+ .openSidebarMenu()
+ .openSettings()
+ .openAlternativeRoutingSettings()
+ .turnOffAlternativeRouting()
+ .verify { alternativeRoutingSettingIsToggled() }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/userrecovery/UserRecoveryFlowTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/userrecovery/UserRecoveryFlowTest.kt
new file mode 100644
index 0000000000..b54b94ba6e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/userrecovery/UserRecoveryFlowTest.kt
@@ -0,0 +1,74 @@
+package ch.protonmail.android.uitest.e2e.userrecovery
+
+import ch.protonmail.android.test.annotations.suite.CoreLibraryTest
+import ch.protonmail.android.uitest.BaseTest
+import ch.protonmail.android.uitest.di.LocalhostApi
+import ch.protonmail.android.uitest.di.LocalhostApiModule
+import ch.protonmail.android.uitest.robot.account.section.buttonsSection
+import ch.protonmail.android.uitest.robot.account.signOutAccountDialogRobot
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.verify
+import ch.protonmail.android.uitest.robot.menu.menuRobot
+import ch.protonmail.android.uitest.util.awaitProgressIsHidden
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.UninstallModules
+import me.proton.core.auth.test.usecase.WaitForPrimaryAccount
+import me.proton.core.domain.entity.UserId
+import me.proton.core.test.quark.Quark
+import me.proton.core.userrecovery.dagger.CoreDeviceRecoveryFeaturesModule
+import me.proton.core.userrecovery.domain.IsDeviceRecoveryEnabled
+import me.proton.core.userrecovery.domain.repository.DeviceRecoveryRepository
+import me.proton.core.userrecovery.presentation.compose.DeviceRecoveryHandler
+import me.proton.core.userrecovery.presentation.compose.DeviceRecoveryNotificationSetup
+import me.proton.core.userrecovery.test.MinimalUserRecoveryTest
+import javax.inject.Inject
+import kotlin.test.BeforeTest
+
+@CoreLibraryTest
+@HiltAndroidTest
+@UninstallModules(
+ LocalhostApiModule::class,
+ CoreDeviceRecoveryFeaturesModule::class
+)
+internal class UserRecoveryFlowTest : BaseTest(), MinimalUserRecoveryTest {
+ override val quark: Quark = BaseTest.quark
+
+ @JvmField
+ @BindValue
+ @LocalhostApi
+ val localhostApi = false
+
+ @Inject
+ override lateinit var deviceRecoveryHandler: DeviceRecoveryHandler
+
+ @Inject
+ override lateinit var deviceRecoveryNotificationSetup: DeviceRecoveryNotificationSetup
+
+ @Inject
+ override lateinit var deviceRecoveryRepository: DeviceRecoveryRepository
+
+ @Inject
+ override lateinit var waitForPrimaryAccount: WaitForPrimaryAccount
+
+ @BindValue
+ internal val isDeviceRecoveryEnabled = object : IsDeviceRecoveryEnabled {
+ override fun invoke(userId: UserId?): Boolean = true
+ override fun isLocalEnabled(): Boolean = true
+ override fun isRemoteEnabled(userId: UserId?): Boolean = true
+ }
+
+ @BeforeTest
+ override fun prepare() {
+ super.prepare()
+ initFusion(composeTestRule)
+ }
+
+ override fun signOut() {
+ menuRobot { openSidebarMenu() }
+ menuRobot { tapSignOut() }
+ signOutAccountDialogRobot {
+ buttonsSection { tapSignOut() }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/CoreLibraryTestFilter.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/CoreLibraryTestFilter.kt
new file mode 100644
index 0000000000..f42b89d424
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/CoreLibraryTestFilter.kt
@@ -0,0 +1,35 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.filters
+
+import androidx.test.filters.AbstractFilter
+import ch.protonmail.android.test.annotations.suite.CoreLibraryTest
+import org.junit.runner.Description
+
+@Suppress("unused") // Used as CLI parameter
+internal class CoreLibraryTestFilter : AbstractFilter() {
+
+ override fun shouldRun(description: Description): Boolean = super.shouldRun(description)
+
+ override fun describe(): String = "Filters core library tests only"
+
+ override fun evaluateTest(description: Description): Boolean {
+ return description.hasAnnotation(CoreLibraryTest::class.java)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/DescriptionExtension.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/DescriptionExtension.kt
new file mode 100644
index 0000000000..18b52124e3
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/DescriptionExtension.kt
@@ -0,0 +1,31 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.filters
+
+import org.junit.runner.Description
+
+/**
+ * Checks whether the given [Description] has a specific [Annotation], either at the method OR test class level.
+ *
+ * @param annotation an annotation class.
+ * @return true if the annotation is present.
+ */
+internal fun Description.hasAnnotation(annotation: Class): Boolean {
+ return getAnnotation(annotation) != null || testClass.isAnnotationPresent(annotation)
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/FullRegressionTestFilter.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/FullRegressionTestFilter.kt
new file mode 100644
index 0000000000..c1853a1939
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/FullRegressionTestFilter.kt
@@ -0,0 +1,37 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.filters
+
+import androidx.test.filters.AbstractFilter
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.test.annotations.suite.SmokeTest
+import org.junit.runner.Description
+
+@Suppress("unused") // Used as CLI parameter
+internal class FullRegressionTestFilter : AbstractFilter() {
+
+ override fun shouldRun(description: Description): Boolean = super.shouldRun(description)
+
+ override fun describe(): String = "Run full regression tests"
+
+ override fun evaluateTest(description: Description): Boolean {
+ return description.hasAnnotation(RegressionTest::class.java) ||
+ description.hasAnnotation(SmokeTest::class.java)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/SmokeTestFilter.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/SmokeTestFilter.kt
new file mode 100644
index 0000000000..cb4475caf9
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/SmokeTestFilter.kt
@@ -0,0 +1,35 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.filters
+
+import androidx.test.filters.AbstractFilter
+import ch.protonmail.android.test.annotations.suite.SmokeTest
+import org.junit.runner.Description
+
+@Suppress("unused") // Used as CLI parameter
+internal class SmokeTestFilter : AbstractFilter() {
+
+ override fun shouldRun(description: Description): Boolean = super.shouldRun(description)
+
+ override fun describe(): String = "Filters smoke tests only"
+
+ override fun evaluateTest(description: Description): Boolean {
+ return description.hasAnnotation(SmokeTest::class.java)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/AppThemeHelper.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/AppThemeHelper.kt
new file mode 100644
index 0000000000..f7eb50dd57
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/AppThemeHelper.kt
@@ -0,0 +1,43 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.helpers.core
+
+import androidx.datastore.preferences.core.stringPreferencesKey
+import ch.protonmail.android.mailcommon.data.mapper.safeEdit
+import ch.protonmail.android.mailsettings.data.MailSettingsDataStoreProvider
+import ch.protonmail.android.mailsettings.domain.model.Theme
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+
+/**
+ * A class helper to force a certain theme in the app.
+ */
+internal class AppThemeHelper @Inject constructor() {
+
+ @Inject
+ lateinit var dataStoreProvider: MailSettingsDataStoreProvider
+
+ private val themePreferenceKey = stringPreferencesKey("themeEnumNamePrefKey")
+
+ fun applyTheme(theme: Theme) = runBlocking {
+ dataStoreProvider.themeDataStore.safeEdit {
+ it[themePreferenceKey] = theme.name
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestId.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestId.kt
new file mode 100644
index 0000000000..46e1797f8f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestId.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.helpers.core
+
+/**
+ * A custom annotation used to uniquely identify tests implementation in the codebase
+ * and cross-reference them with the definitions in our test management tool.
+ */
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.FUNCTION)
+annotation class TestId(vararg val values: String)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestIdWatcher.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestIdWatcher.kt
new file mode 100644
index 0000000000..7585b89b00
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestIdWatcher.kt
@@ -0,0 +1,72 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.helpers.core
+
+import java.util.logging.Logger
+import ch.protonmail.android.uitest.util.InstrumentationHolder.instrumentation
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import me.proton.core.presentation.utils.showToast
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+/**
+ * A custom [TestWatcher] that logs the beginning and the end of a test execution along with its [TestId]s (if any).
+ *
+ * At the beginning of the test, it also shows a Toast to help cross-reference the implementation
+ * with the scenario described in the test management tool.
+ */
+internal class TestIdWatcher : TestWatcher() {
+
+ private val Description.testIds: String?
+ get() {
+ val annotation = annotations.find { it is TestId } as? TestId
+ return annotation?.values?.joinToString()
+ }
+
+ private val Description.classMethodName: String
+ get() = "$className#$methodName"
+
+ override fun starting(description: Description) {
+ super.starting(description)
+
+ val testIds = description.testIds ?: return
+
+ // Needs to run on the main thread, otherwise it won't show anything.
+ runBlocking(Dispatchers.Main) {
+ instrumentation.targetContext.showToast("Test ID(s) - $testIds", DefaultToastLength)
+ }
+
+ logger.info("Starting Test ID(s) $testIds - ${description.classMethodName}.")
+ }
+
+ override fun finished(description: Description) {
+ super.finished(description)
+
+ description.testIds?.let { testIds ->
+ logger.info("Finished Test ID(s) $testIds - ${description.classMethodName}.")
+ }
+ }
+
+ companion object {
+
+ private val logger = Logger.getLogger(this::class.java.name)
+ private const val DefaultToastLength = 5000
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestingNotes.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestingNotes.kt
new file mode 100644
index 0000000000..653fa4c056
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestingNotes.kt
@@ -0,0 +1,23 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.helpers.core
+
+@Retention(AnnotationRetention.SOURCE)
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+internal annotation class TestingNotes(@Suppress("unused") val note: String)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/navigation/Destination.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/navigation/Destination.kt
new file mode 100644
index 0000000000..03c013bd9a
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/navigation/Destination.kt
@@ -0,0 +1,36 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.helpers.core.navigation
+
+/**
+ * A [Destination] represents a screen of the Proton Mail app.
+ */
+internal sealed class Destination {
+
+ object Onboarding : Destination()
+ object Inbox : Destination()
+ object Drafts : Destination()
+ object Archive : Destination()
+ object Spam : Destination()
+ object Trash : Destination()
+ object Composer : Destination()
+ class MailDetail(val messagePosition: Int = 0) : Destination()
+ class EditDraft(val draftPosition: Int = 0) : Destination()
+ object SidebarMenu : Destination()
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/navigation/Navigator.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/navigation/Navigator.kt
new file mode 100644
index 0000000000..dccc7bec78
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/navigation/Navigator.kt
@@ -0,0 +1,120 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.helpers.core.navigation
+
+import androidx.test.espresso.Espresso
+import ch.protonmail.android.test.ksp.annotations.AsDsl
+import ch.protonmail.android.uitest.helpers.login.MockedLoginTestUsers
+import ch.protonmail.android.uitest.robot.common.section.fullscreenLoaderSection
+import ch.protonmail.android.uitest.robot.composer.composerRobot
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.section.topAppBarSection
+import ch.protonmail.android.uitest.robot.menu.menuRobot
+import ch.protonmail.android.uitest.util.ActivityScenarioHolder
+import ch.protonmail.android.uitest.util.extensions.waitUntilSignInScreenIsGone
+import me.proton.core.test.android.robots.auth.AddAccountRobot
+import me.proton.core.test.android.robots.auth.login.LoginRobot
+
+/**
+ * An abstraction to help navigating the app in UI tests to reduce the overall verbosity.
+ */
+@AsDsl
+internal class Navigator {
+
+ private val addAccountRobot = AddAccountRobot()
+
+ /**
+ * Triggers the launch of the app and waits for an idle state (via Espresso).
+ *
+ * The Compose test rule here is not used as the entry point when launching the app
+ * will never contain a Compose hierarchy for now (as it's the common Core sign in/up screen).
+ */
+ fun openApp() {
+ ActivityScenarioHolder.initialize()
+ Espresso.onIdle()
+ }
+
+ /**
+ * Navigates to a given [Destination].
+ *
+ * The navigation shall always be performed at the beginning of the test, as it assumes that the initial state
+ * will always either be the "Add account" screen (from the Core library) or the Inbox.
+ *
+ * @param destination the destination
+ * @param launchApp whether the app shall be launched.
+ * @param performLoginViaUI whether the login flow shall be performed via UI
+ */
+ fun navigateTo(
+ destination: Destination,
+ launchApp: Boolean = true,
+ performLoginViaUI: Boolean = true
+ ) {
+ if (launchApp) openApp()
+
+ if (performLoginViaUI) login()
+
+ when (destination) {
+ is Destination.Onboarding,
+ is Destination.Inbox -> Unit // It's the default screen post-login, nothing to do.
+ is Destination.Drafts -> menuRobot {
+ openSidebarMenu()
+ openDrafts()
+ }
+
+ is Destination.Archive -> menuRobot {
+ openSidebarMenu()
+ openArchive()
+ }
+
+ is Destination.Spam -> menuRobot {
+ openSidebarMenu()
+ openSpam()
+ }
+
+ is Destination.Trash -> menuRobot {
+ openSidebarMenu()
+ openTrash()
+ }
+
+ is Destination.Composer -> mailboxRobot {
+ topAppBarSection { tapComposerIcon() }
+ }
+
+ is Destination.MailDetail -> mailboxRobot {
+ listSection { clickMessageByPosition(destination.messagePosition) }
+ }
+
+ is Destination.EditDraft -> {
+ navigateTo(Destination.Drafts, launchApp = false, performLoginViaUI = false)
+ mailboxRobot { listSection { clickMessageByPosition(destination.draftPosition) } }
+ composerRobot { fullscreenLoaderSection { waitUntilGone() } }
+ }
+
+ is Destination.SidebarMenu -> menuRobot { openSidebarMenu() }
+ }
+ }
+
+ private fun login() {
+ addAccountRobot
+ .signIn()
+ .loginUser(MockedLoginTestUsers.defaultLoginUser)
+ .waitUntilSignInScreenIsGone()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/LoginTestUserTypes.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/LoginTestUserTypes.kt
new file mode 100644
index 0000000000..6270cc1209
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/LoginTestUserTypes.kt
@@ -0,0 +1,41 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.helpers.login
+
+object LoginTestUserTypes {
+ object Free {
+
+ val SleepyKoala = LoginType.LoggedIn("slpkla")
+ }
+
+ object Paid {
+
+ val FancyCapybara = LoginType.LoggedIn("fncyra")
+ }
+
+ object External {
+
+ val StrangeWalrus = LoginType.LoggedIn("stgwrs")
+ }
+
+ object Deprecated {
+
+ val GrumpyCat = LoginType.LoggedIn("gmpcat")
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/LoginType.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/LoginType.kt
new file mode 100644
index 0000000000..27d7a23124
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/LoginType.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.helpers.login
+
+sealed class LoginType {
+
+ class LoggedIn(val id: String) : LoginType()
+ object LoggedOut : LoginType()
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/MockedLoginTestUsers.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/MockedLoginTestUsers.kt
new file mode 100644
index 0000000000..446afdac69
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/MockedLoginTestUsers.kt
@@ -0,0 +1,30 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.helpers.login
+
+import me.proton.core.test.quark.data.User
+
+internal object MockedLoginTestUsers {
+
+ val defaultLoginUser = User(
+ name = "fake.user",
+ password = "password",
+ passphrase = "password"
+ )
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/AuthenticationDispatcher.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/AuthenticationDispatcher.kt
new file mode 100644
index 0000000000..c5f97a0c31
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/AuthenticationDispatcher.kt
@@ -0,0 +1,50 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.helpers.network
+
+import ch.protonmail.android.networkmocks.mockwebserver.MockNetworkDispatcher
+import ch.protonmail.android.networkmocks.mockwebserver.requests.get
+import ch.protonmail.android.networkmocks.mockwebserver.requests.post
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+import ch.protonmail.android.uitest.helpers.login.LoginType
+
+/**
+ * Returns a [MockNetworkDispatcher] instance with valid authenticator mocks depending on the passed [LoginType].
+ *
+ * @param loginType the login type (logged in, out).
+ *
+ * @return an instance of [MockNetworkDispatcher] with predefined mock definitions.
+ */
+internal fun authenticationDispatcher(loginType: LoginType) = MockNetworkDispatcher().apply {
+ val id = when (loginType) {
+ is LoginType.LoggedIn -> loginType.id
+ else -> return@apply
+ }
+
+ addMockRequests(
+ post("/auth/v4") respondWith "/auth/v4/auth-v4_$id.json" withStatusCode 200,
+ post("/auth/v4/info") respondWith "/auth/v4/info/info_$id.json" withStatusCode 200,
+ post("/auth/v4/sessions") respondWith "/auth/v4/sessions/sessions_$id.json" withStatusCode 200,
+ get("/core/v4/users") respondWith "/core/v4/users/users_$id.json" withStatusCode 200,
+ get("/core/v4/addresses") respondWith "/core/v4/addresses/addresses_$id.json" withStatusCode 200,
+ get("/core/v4/keys/salts") respondWith "/core/v4/keys/salts/salts_$id.json" withStatusCode 200,
+ get("/auth/v4/scopes") respondWith "/auth/v4/scopes/scopes_$id.json" withStatusCode 200
+ )
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/DefaultNetworkDispatcher.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/DefaultNetworkDispatcher.kt
new file mode 100644
index 0000000000..0c35d2bf52
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/DefaultNetworkDispatcher.kt
@@ -0,0 +1,187 @@
+/*
+ * 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 .
+ */
+
+@file:SuppressWarnings("MagicNumber", "MaxLineLength", "LongParameterList")
+
+package ch.protonmail.android.uitest.helpers.network
+
+import ch.protonmail.android.networkmocks.mockwebserver.MockNetworkDispatcher
+import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority
+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.post
+import ch.protonmail.android.networkmocks.mockwebserver.requests.put
+import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority
+import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode
+
+/**
+ * A base top level function that provides a [MockNetworkDispatcher] instance
+ * with default values that can be easily overridden.
+ */
+internal fun mockNetworkDispatcher(
+ useDefaultCoreSettings: Boolean = true,
+ useDefaultMailSettings: Boolean = true,
+ useDefaultContacts: Boolean = true,
+ useDefaultFeatures: Boolean = true,
+ useDefaultUnleashToggles: Boolean = true,
+ useDefaultLabels: Boolean = true,
+ useDefaultContactGroups: Boolean = true,
+ useDefaultCustomFolders: Boolean = true,
+ useDefaultSystemFolders: Boolean = true,
+ useDefaultPaymentSettings: Boolean = true,
+ useDefaultMailReadResponses: Boolean = true,
+ useDefaultDeviceRegistration: Boolean = true,
+ useDefaultCounters: Boolean = true,
+ ignoreEvents: Boolean = true,
+ additionalMockDefinitions: MockNetworkDispatcher.() -> Unit = {}
+) = MockNetworkDispatcher().apply {
+
+ if (useDefaultCoreSettings) {
+ addMockRequests(
+ get("/core/v4/settings")
+ respondWith "/core/v4/settings/core-v4-settings_base_placeholder.json"
+ withStatusCode 200
+ )
+ }
+
+ if (useDefaultMailSettings) {
+ addMockRequests(
+ get("/mail/v4/settings")
+ respondWith "/mail/v4/settings/mail-v4-settings_base_placeholder.json"
+ withStatusCode 200
+ )
+ }
+
+ if (useDefaultContacts) {
+ addMockRequests(
+ get("/contacts/v4/contacts")
+ respondWith "/contacts/v4/contacts/contacts_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true,
+ get("/contacts/v4/contacts/emails")
+ respondWith "/contacts/v4/contacts/emails/contacts-emails_base_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ if (useDefaultFeatures) {
+ addMockRequests(
+ get("/core/v4/features")
+ respondWith "/core/v4/features/features_empty_placeholder.json"
+ withStatusCode 200 ignoreQueryParams true
+ )
+ }
+
+ if (useDefaultUnleashToggles) {
+ addMockRequests(
+ get("/feature/v2/frontend")
+ respondWith "/feature/v2/frontend/frontend_empty_placeholder.json"
+ withStatusCode 200
+ )
+ }
+
+ if (useDefaultLabels) {
+ addMockRequests(
+ get("/core/v4/labels?Type=1")
+ respondWith "/core/v4/labels/labels-type1_base_placeholder.json"
+ withStatusCode 200
+ )
+ }
+
+ if (useDefaultContactGroups) {
+ addMockRequests(
+ get("/core/v4/labels?Type=2")
+ respondWith "/core/v4/labels/labels-type2_base_placeholder.json"
+ withStatusCode 200
+ )
+ }
+
+ if (useDefaultCustomFolders) {
+ addMockRequests(
+ get("/core/v4/labels?Type=3")
+ respondWith "/core/v4/labels/labels-type3_base_placeholder.json"
+ withStatusCode 200
+ )
+ }
+
+ if (useDefaultSystemFolders) {
+ addMockRequests(
+ get("/core/v4/labels?Type=4")
+ respondWith "/core/v4/labels/labels-type4_base_placeholder.json"
+ withStatusCode 200
+ )
+ }
+
+ if (useDefaultPaymentSettings) {
+ addMockRequests(
+ get("/payments/v4/status/google")
+ respondWith "/payments/v4/status/google/payments_empty.json"
+ withStatusCode 200
+ )
+ }
+
+ if (useDefaultDeviceRegistration) {
+ addMockRequests(
+ post("/core/v4/devices")
+ respondWith "/core/v4/devices/devices_base_placeholder.json"
+ withStatusCode 200
+ )
+ }
+
+ if (useDefaultMailReadResponses) {
+ addMockRequests(
+ put("/mail/v4/messages/read")
+ respondWith "/mail/v4/messages/read/read_base_placeholder.json"
+ withStatusCode 200 withPriority MockPriority.Highest,
+ put("/mail/v4/conversations/read")
+ respondWith "/mail/v4/conversations/read/conversations_read_base_placeholder.json"
+ withStatusCode 200 withPriority MockPriority.Highest
+ )
+ }
+
+ if (ignoreEvents) {
+ addMockRequests(
+ get("/core/v5/events/*")
+ respondWith "/core/v5/events/event-id/event-v5_base_placeholder.json"
+ withStatusCode 200 matchWildcards true,
+ get("/core/v5/events/latest")
+ respondWith "/core/v5/events/latest/events-v5-latest_base_placeholder.json"
+ withStatusCode 200,
+ get("/core/v4/events/*")
+ respondWith "/core/v4/events/event-id/event_base_placeholder.json"
+ withStatusCode 200 matchWildcards true,
+ get("/core/v4/events/latest")
+ respondWith "/core/v4/events/latest/events-latest_base_placeholder.json"
+ withStatusCode 200
+ )
+ }
+
+ if (useDefaultCounters) {
+ addMockRequests(
+ get("/mail/v4/conversations/count")
+ respondWith "/mail/v4/conversations/count/conversations-count_base_placeholder.json"
+ withStatusCode 200,
+ get("/mail/v4/messages/count")
+ respondWith "/mail/v4/messages/count/messages-count_base_placeholder.json"
+ withStatusCode 200
+ )
+ }
+
+ additionalMockDefinitions()
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/NetworkManagerExtensions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/NetworkManagerExtensions.kt
new file mode 100644
index 0000000000..c5026e06b1
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/NetworkManagerExtensions.kt
@@ -0,0 +1,36 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.helpers.network
+
+import io.mockk.every
+import kotlinx.coroutines.flow.flowOf
+import me.proton.core.network.domain.NetworkManager
+import me.proton.core.network.domain.NetworkStatus
+
+internal fun NetworkManager.enableNetwork() {
+ every { observe() } returns flowOf(NetworkStatus.Unmetered)
+ every { networkStatus } returns NetworkStatus.Unmetered
+ every { isConnectedToNetwork() } returns true
+}
+
+internal fun NetworkManager.disableNetwork() {
+ every { observe() } returns flowOf(NetworkStatus.Disconnected)
+ every { networkStatus } returns NetworkStatus.Disconnected
+ every { isConnectedToNetwork() } returns false
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/avatar/AvatarInitial.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/avatar/AvatarInitial.kt
new file mode 100644
index 0000000000..868dce9ff7
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/avatar/AvatarInitial.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.avatar
+
+sealed class AvatarInitial {
+
+ object Draft : AvatarInitial()
+ class WithText(val text: String) : AvatarInitial()
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/bottombar/BottomBarActionEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/bottombar/BottomBarActionEntry.kt
new file mode 100644
index 0000000000..39818ac412
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/bottombar/BottomBarActionEntry.kt
@@ -0,0 +1,63 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.bottombar
+
+import ch.protonmail.android.uitest.util.getTestString
+import ch.protonmail.android.test.R as testR
+
+// Every inheritor has a default index which reflects the current implementation in App.
+internal sealed class BottomBarActionEntry(val index: Int, val description: String) {
+
+ class MarkAsRead(index: Int = 0) :
+ BottomBarActionEntry(index, getTestString(testR.string.test_action_mark_read_content_description))
+
+ class MarkAsUnread(index: Int = 0) :
+ BottomBarActionEntry(index, getTestString(testR.string.test_action_mark_unread_content_description))
+
+ class Trash(index: Int = 1) :
+ BottomBarActionEntry(index, getTestString(testR.string.test_action_trash_content_description))
+
+ class Delete(index: Int = 1) :
+ BottomBarActionEntry(index, getTestString(testR.string.test_action_delete_content_description))
+
+ class MoveTo(index: Int = 2) :
+ BottomBarActionEntry(index, getTestString(testR.string.test_action_move_content_description))
+
+ class LabelAs(index: Int = 3) :
+ BottomBarActionEntry(index, getTestString(testR.string.test_action_label_content_description))
+
+ object More :
+ BottomBarActionEntry(index = LastItemIndex, getTestString(testR.string.test_action_more_content_description))
+
+ object Defaults {
+
+ val actionsOnReadItem = arrayOf(MarkAsUnread(), Trash(), MoveTo(), LabelAs(), More)
+ val actionsOnUnreadItem = arrayOf(MarkAsRead(), Trash(), MoveTo(), LabelAs(), More)
+ val actionsOnTrashedReadItem = arrayOf(MarkAsUnread(), Delete(), MoveTo(), LabelAs(), More)
+ val actionsOnTrashedUnreadItem = arrayOf(MarkAsRead(), Delete(), MoveTo(), LabelAs(), More)
+ val actionsOnSpamReadItem = arrayOf(MarkAsUnread(), Delete(), MoveTo(), LabelAs(), More)
+ val actionsOnSpamUnreadItem = arrayOf(MarkAsRead(), Delete(), MoveTo(), LabelAs(), More)
+ }
+
+ private companion object {
+
+ private const val TotalItemsThreshold = 5
+ private const val LastItemIndex = TotalItemsThreshold - 1
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/bottombar/BottomBarActionEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/bottombar/BottomBarActionEntryModel.kt
new file mode 100644
index 0000000000..0fd731a067
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/bottombar/BottomBarActionEntryModel.kt
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.bottombar
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertContentDescriptionEquals
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailcommon.presentation.ui.BottomActionBarTestTags
+import ch.protonmail.android.uitest.util.child
+
+internal class BottomBarActionEntryModel(private val index: Int, parent: SemanticsNodeInteraction) {
+
+ private val item = parent.child { hasTestTag("${BottomActionBarTestTags.Button}$index") }
+
+ // region actions
+ fun click() = item.performClick()
+ // endregion
+
+ // region verification
+ fun hasDescription(description: String) = item.assertContentDescriptionEquals(description)
+ // endregion
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRecipientEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRecipientEntry.kt
new file mode 100644
index 0000000000..04af91824e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRecipientEntry.kt
@@ -0,0 +1,66 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.detail
+
+internal enum class RecipientKind {
+ To,
+ Cc,
+ Bcc
+}
+
+internal sealed class ExtendedHeaderRecipientEntry(
+ val kind: RecipientKind,
+ val index: Int,
+ val name: String,
+ val address: String
+) {
+
+ class To(
+ index: Int,
+ name: String,
+ address: String
+ ) : ExtendedHeaderRecipientEntry(
+ kind = RecipientKind.To,
+ index = index,
+ name = name,
+ address = address
+ )
+
+ class Cc(
+ index: Int,
+ name: String,
+ address: String
+ ) : ExtendedHeaderRecipientEntry(
+ kind = RecipientKind.Cc,
+ index = index,
+ name = name,
+ address = address
+ )
+
+ class Bcc(
+ index: Int,
+ name: String,
+ address: String
+ ) : ExtendedHeaderRecipientEntry(
+ kind = RecipientKind.Bcc,
+ index = index,
+ name = name,
+ address = address
+ )
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRecipientEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRecipientEntryModel.kt
new file mode 100644
index 0000000000..f179a92a8c
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRecipientEntryModel.kt
@@ -0,0 +1,47 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.detail
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import ch.protonmail.android.maildetail.presentation.ui.header.MessageDetailHeaderTestTags
+import ch.protonmail.android.uitest.util.children
+
+internal class ExtendedHeaderRecipientEntryModel(
+ parent: SemanticsNodeInteraction,
+ index: Int
+) {
+
+ private val name = parent.children {
+ hasTestTag(MessageDetailHeaderTestTags.ParticipantName)
+ }[index]
+
+ private val address = parent.children {
+ hasTestTag(MessageDetailHeaderTestTags.ParticipantValue)
+ }[index]
+
+ fun hasName(value: String) = apply {
+ name.assertTextEquals(value)
+ }
+
+ fun hasAddress(value: String) = apply {
+ address.assertTextEquals(value)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRowEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRowEntryModel.kt
new file mode 100644
index 0000000000..fc5253ff25
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRowEntryModel.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.models.detail
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import ch.protonmail.android.maildetail.presentation.ui.header.MessageDetailHeaderTestTags
+import ch.protonmail.android.uitest.util.child
+
+internal class ExtendedHeaderRowEntryModel(parent: SemanticsNodeInteraction) {
+
+ private val icon = parent.child {
+ hasTestTag(MessageDetailHeaderTestTags.ExtendedHeaderIcon)
+ }
+
+ private val text = parent.child {
+ hasTestTag(MessageDetailHeaderTestTags.ExtendedHeaderText)
+ }
+
+ // region verification
+ fun hasIcon() = apply {
+ icon.assertExists()
+ }
+
+ fun hasText(value: String) = apply {
+ text.assertTextEquals(value)
+ }
+ // endregion
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/MessageHeaderEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/MessageHeaderEntryModel.kt
new file mode 100644
index 0000000000..6acbeddc3f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/MessageHeaderEntryModel.kt
@@ -0,0 +1,192 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.detail
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailcommon.presentation.compose.AvatarTestTags
+import ch.protonmail.android.mailcommon.presentation.compose.OfficialBadgeTestTags
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailItemTestTags
+import ch.protonmail.android.maildetail.presentation.ui.header.MessageDetailHeaderTestTags
+import ch.protonmail.android.test.R
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.labels.LabelEntry
+import ch.protonmail.android.uitest.models.labels.LabelEntryModel
+import ch.protonmail.android.uitest.util.child
+import ch.protonmail.android.uitest.util.getTestString
+
+@Suppress("TooManyFunctions")
+internal class MessageHeaderEntryModel(
+ composeTestRule: ComposeTestRule
+) {
+
+ private val collapseAnchor = composeTestRule.onNodeWithTag(
+ testTag = ConversationDetailItemTestTags.CollapseAnchor
+ )
+
+ private val rootItem = composeTestRule.onNodeWithTag(
+ testTag = MessageDetailHeaderTestTags.RootItem,
+ useUnmergedTree = true
+ )
+
+ private val quickActionsItem = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.ActionsRootItem)
+ }
+
+ private val avatarRootItem = rootItem.child {
+ hasTestTag(AvatarTestTags.AvatarRootItem)
+ }
+
+ private val avatar = avatarRootItem.child {
+ hasTestTag(AvatarTestTags.AvatarText)
+ }
+
+ private val avatarDraft = avatarRootItem.child {
+ hasTestTag(AvatarTestTags.AvatarDraft)
+ }
+
+ private val senderName = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.SenderName)
+ }
+
+ private val senderAddress = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.SenderAddress)
+ }
+
+ private val authenticityBadge = rootItem.child {
+ hasTestTag(OfficialBadgeTestTags.Item)
+ }
+
+ private val icons = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.Icons)
+ }
+
+ private val time = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.Time)
+ }
+
+ private val replyButton = quickActionsItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.ReplyButton)
+ }
+
+ private val replyAllButton = quickActionsItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.ReplyAllButton)
+ }
+
+ private val moreButton = quickActionsItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.MoreButton)
+ }
+
+ private val recipientsText = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.AllRecipientsText)
+ }
+
+ private val recipientsValue = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.AllRecipientsValue)
+ }
+
+ private val labelsList = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.LabelsList)
+ }
+
+ // region actions
+ fun click() = apply {
+ rootItem.performClick()
+ }
+
+ fun tapReplyButton() = apply {
+ replyButton.performClick()
+ }
+
+ fun collapseMessage() = apply {
+ collapseAnchor.performClick()
+ }
+ // endregion
+
+ // region verification
+ fun isDisplayed() = apply {
+ rootItem.assertExists()
+ }
+
+ fun hasAvatar(initial: AvatarInitial) = apply {
+ when (initial) {
+ is AvatarInitial.WithText -> avatar.assertTextEquals(initial.text)
+ is AvatarInitial.Draft -> avatarDraft.assertIsDisplayed()
+ }
+ }
+
+ fun hasSenderName(name: String) = apply {
+ senderName.assertTextEquals(name)
+ }
+
+ fun hasAuthenticityBadge(expectedValue: Boolean) {
+ if (expectedValue) {
+ authenticityBadge.assertIsDisplayed()
+ authenticityBadge.assertTextEquals(getTestString(R.string.test_auth_badge_official))
+ } else {
+ authenticityBadge.assertDoesNotExist()
+ }
+ }
+
+ fun hasSenderAddress(address: String) = apply {
+ senderAddress.assertTextEquals(address)
+ }
+
+ fun hasMoreButton() = apply {
+ moreButton.assertIsDisplayed()
+ }
+
+ fun hasIcons() = apply {
+ icons.assertIsDisplayed()
+ }
+
+ fun hasNoIcons() = apply {
+ icons.assertIsNotDisplayed()
+ }
+
+ fun hasDate(date: String) = apply {
+ time.assertTextEquals(date)
+ }
+
+ fun hasRecipient(recipient: String) = apply {
+ recipientsText.assertIsDisplayed()
+ recipientsValue.assertTextEquals(recipient)
+ }
+
+ fun hasLabels(vararg entries: LabelEntry) = apply {
+ entries.forEach {
+ val model = LabelEntryModel(labelsList, it.index)
+ model.hasText(it.text)
+ }
+ }
+
+ fun hasReplyButton() = apply {
+ replyButton.assertIsDisplayed()
+ }
+
+ fun hasReplyAllButton() = apply {
+ replyAllButton.assertIsDisplayed()
+ }
+ // endregion
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/MessageHeaderExpandedEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/MessageHeaderExpandedEntryModel.kt
new file mode 100644
index 0000000000..27628a536c
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/MessageHeaderExpandedEntryModel.kt
@@ -0,0 +1,122 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.detail
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailItemTestTags
+import ch.protonmail.android.maildetail.presentation.ui.header.MessageDetailHeaderTestTags
+import ch.protonmail.android.uitest.models.labels.LabelEntry
+import ch.protonmail.android.uitest.models.labels.LabelEntryModel
+import ch.protonmail.android.uitest.util.child
+
+internal class MessageHeaderExpandedEntryModel(composeTestRule: ComposeTestRule) {
+
+ // In the layout, it's outside of the root item
+ private val collapseAnchor = composeTestRule.onNodeWithTag(
+ testTag = ConversationDetailItemTestTags.CollapseAnchor
+ )
+
+ private val rootItem = composeTestRule.onNodeWithTag(
+ testTag = MessageDetailHeaderTestTags.RootItem,
+ useUnmergedTree = true
+ )
+
+ // The structure of the Labels component is a bit different than the others.
+ private val labels = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.ExtendedLabelRow)
+ }
+
+ private val time = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.ExtendedTimeRow)
+ }.asExtendedHeaderRowEntryModel()
+
+ private val location = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.ExtendedFolderRow)
+ }.asExtendedHeaderRowEntryModel()
+
+ private val size = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.ExtendedSizeRow)
+ }.asExtendedHeaderRowEntryModel()
+
+ private val hideDetailsButton = rootItem.child {
+ hasTestTag(MessageDetailHeaderTestTags.ExtendedHideDetails)
+ }
+
+ // region actions
+ fun collapse() {
+ collapseAnchor.performClick()
+ }
+ // endregion
+
+ // region verification
+ fun hasRecipients(vararg recipients: ExtendedHeaderRecipientEntry) {
+ recipients.forEach {
+ val model = it.asEntryModel()
+ model.hasName(it.name)
+ .hasAddress(it.address)
+ }
+ }
+
+ fun hasLabels(vararg entries: LabelEntry) {
+ labels.child { hasTestTag(MessageDetailHeaderTestTags.LabelIcon) }.assertExists()
+
+ entries.forEach {
+ val model = LabelEntryModel(labels, it.index)
+ model.hasText(it.text)
+ }
+ }
+
+ fun hasTime(value: String) {
+ time.hasIcon()
+ .hasText(value)
+ }
+
+ fun hasLocation(value: String) {
+ location.hasIcon()
+ .hasText(value)
+ }
+
+ fun hasSize(value: String) {
+ size.hasIcon()
+ .hasText(value)
+ }
+
+ fun hasHideDetailsButton() {
+ hideDetailsButton.assertIsDisplayed()
+ }
+ // endregion
+
+ private fun ExtendedHeaderRecipientEntry.asEntryModel(): ExtendedHeaderRecipientEntryModel {
+ val testTag = when (kind) {
+ RecipientKind.To -> MessageDetailHeaderTestTags.ToRecipientsList
+ RecipientKind.Cc -> MessageDetailHeaderTestTags.CcRecipientsList
+ RecipientKind.Bcc -> MessageDetailHeaderTestTags.BccRecipientsList
+ }
+
+ val parent = rootItem.child { hasTestTag(testTag) }
+ return ExtendedHeaderRecipientEntryModel(parent, index)
+ }
+
+ private fun SemanticsNodeInteraction.asExtendedHeaderRowEntryModel() = ExtendedHeaderRowEntryModel(this)
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/MailFolderEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/MailFolderEntry.kt
new file mode 100644
index 0000000000..e1baa9c9eb
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/MailFolderEntry.kt
@@ -0,0 +1,24 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.folders
+
+internal data class MailFolderEntry(
+ val index: Int,
+ val iconTint: Tint
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/MailLabelEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/MailLabelEntry.kt
new file mode 100644
index 0000000000..649361af63
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/MailLabelEntry.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.folders
+
+internal data class MailLabelEntry(
+ val index: Int,
+ val name: String,
+ val backgroundTint: Tint = Tint.NoColor
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarCustomItemEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarCustomItemEntry.kt
new file mode 100644
index 0000000000..a7e30ff9ab
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarCustomItemEntry.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.folders
+
+internal data class SidebarCustomItemEntry(
+ val index: Int,
+ val name: String,
+ val iconTint: Tint
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarItemCustomEntryModels.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarItemCustomEntryModels.kt
new file mode 100644
index 0000000000..061645566d
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarItemCustomEntryModels.kt
@@ -0,0 +1,35 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.folders
+
+import androidx.compose.ui.test.hasTestTag
+
+internal class SidebarItemCustomLabelEntryModel(
+ position: Int
+) : SidebarItemEntryModel(position, hasTestTag(TestTags.SidebarItemCustomLabel))
+
+internal class SidebarItemCustomFolderEntryModel(
+ position: Int
+) : SidebarItemEntryModel(position, hasTestTag(TestTags.SidebarItemCustomFolder))
+
+private object TestTags {
+
+ const val SidebarItemCustomFolder = "SidebarItemCustomFolder"
+ const val SidebarItemCustomLabel = "SidebarItemCustomLabel"
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarItemEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarItemEntryModel.kt
new file mode 100644
index 0000000000..fd13bd25ce
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarItemEntryModel.kt
@@ -0,0 +1,56 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.folders
+
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import ch.protonmail.android.maillabel.presentation.sidebar.SidebarCustomLabelTestTags
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+import ch.protonmail.android.uitest.util.assertions.assertTintColor
+import ch.protonmail.android.uitest.util.child
+
+internal sealed class SidebarItemEntryModel(
+ position: Int,
+ matcher: SemanticsMatcher,
+ composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule
+) {
+
+ private val rootItem = composeTestRule.onAllNodes(
+ matcher = matcher,
+ useUnmergedTree = true
+ )[position]
+
+ private val icon = rootItem.child {
+ hasTestTag(SidebarCustomLabelTestTags.Icon)
+ }
+
+ private val text = rootItem.child {
+ hasTestTag(SidebarCustomLabelTestTags.Text)
+ }
+
+ fun hasText(value: String) = apply {
+ text.assertTextEquals(value)
+ }
+
+ fun withIconTint(tint: Tint) = apply {
+ icon.assertTintColor(tint)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/Tint.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/Tint.kt
new file mode 100644
index 0000000000..f53b2df023
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/Tint.kt
@@ -0,0 +1,33 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.folders
+
+import androidx.compose.ui.graphics.Color
+
+internal sealed class Tint {
+ object NoColor : Tint()
+
+ sealed class WithColor(val value: Color) : Tint() {
+ object Carrot : WithColor(Color(0xFFF78400))
+ object Fern : WithColor(Color(0xFF3CBB3A))
+ object PurpleBase : WithColor(Color(0xFF8080FF))
+ object Bridge : WithColor(Color(0xFFFF6666))
+ class Custom(hex: Long) : WithColor(Color(hex))
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/labels/LabelEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/labels/LabelEntry.kt
new file mode 100644
index 0000000000..2af0b9209d
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/labels/LabelEntry.kt
@@ -0,0 +1,24 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.labels
+
+internal data class LabelEntry(
+ val index: Int,
+ val text: String
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/labels/LabelEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/labels/LabelEntryModel.kt
new file mode 100644
index 0000000000..cd1466ad3d
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/labels/LabelEntryModel.kt
@@ -0,0 +1,41 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.labels
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import ch.protonmail.android.maillabel.presentation.ui.LabelsListTestTags
+import ch.protonmail.android.uitest.util.children
+
+internal class LabelEntryModel(
+ parent: SemanticsNodeInteraction,
+ index: Int,
+) {
+
+ private val labelItem = parent.children {
+ hasTestTag(LabelsListTestTags.Label)
+ }[index]
+
+ // region actions
+ fun hasText(text: String) = apply {
+ labelItem.assertTextEquals(text)
+ }
+ // endregion
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxListItemEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxListItemEntry.kt
new file mode 100644
index 0000000000..94ade85393
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxListItemEntry.kt
@@ -0,0 +1,34 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.mailbox
+
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.folders.MailFolderEntry
+import ch.protonmail.android.uitest.models.folders.MailLabelEntry
+
+internal data class MailboxListItemEntry(
+ val index: Int,
+ val avatarInitial: AvatarInitial,
+ val participants: List,
+ val locationIcons: List? = null,
+ val labels: List? = null,
+ val subject: String,
+ val date: String,
+ val count: String? = null
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxListItemEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxListItemEntryModel.kt
new file mode 100644
index 0000000000..7273aab2b5
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxListItemEntryModel.kt
@@ -0,0 +1,217 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.mailbox
+
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.assertIsNotSelected
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.longClick
+import androidx.compose.ui.test.onChildAt
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollToNode
+import androidx.compose.ui.test.performTouchInput
+import ch.protonmail.android.mailcommon.presentation.compose.AvatarTestTags
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxItemTestTags
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.folders.MailFolderEntry
+import ch.protonmail.android.uitest.models.folders.MailLabelEntry
+import ch.protonmail.android.uitest.util.assertions.assertItemIsRead
+import ch.protonmail.android.uitest.util.assertions.assertTintColor
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.child
+import kotlin.time.Duration.Companion.seconds
+
+internal class MailboxListItemEntryModel(
+ private val position: Int,
+ composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule
+) {
+
+ private val parent: SemanticsNodeInteraction = composeTestRule.onNodeWithTag(
+ MailboxScreenTestTags.List,
+ useUnmergedTree = true
+ )
+
+ private val itemMatcher: SemanticsMatcher
+ get() = hasTestTag("${MailboxItemTestTags.ItemRow}$position")
+
+ private val item: SemanticsNodeInteraction = parent.child { itemMatcher }
+
+ private val avatarRootItem = item.child {
+ hasTestTag(AvatarTestTags.AvatarRootItem)
+ }
+
+ private val avatar = avatarRootItem.child {
+ hasTestTag(AvatarTestTags.AvatarText)
+ }
+
+ private val avatarDraft = avatarRootItem.child {
+ hasTestTag(AvatarTestTags.AvatarDraft)
+ }
+
+ private val avatarSelected = avatarRootItem.child {
+ hasTestTag(AvatarTestTags.AvatarSelectionMode)
+ }
+
+ private val locations = item.child {
+ hasTestTag(MailboxItemTestTags.LocationIcons)
+ }
+
+ private val labels = item.child {
+ hasTestTag(MailboxItemTestTags.LabelsList)
+ }
+
+ private val subject = item.child {
+ hasTestTag(MailboxItemTestTags.Subject)
+ }
+
+ private val date = item.child {
+ hasTestTag(MailboxItemTestTags.Date)
+ }
+
+ private val count = item.child {
+ hasTestTag(MailboxItemTestTags.Count)
+ }
+
+ init {
+ waitForItemToBeShown()
+ }
+
+ // region actions
+ fun click() = apply {
+ item.performClick()
+ }
+
+ fun longClick() = apply {
+ item.performTouchInput { longClick() }
+ }
+
+ fun selectEntry() = apply {
+ val semanticsNode = if (avatarSelected.peekIsDisplayed()) avatarSelected else avatar
+ semanticsNode.performClick()
+ }
+
+ fun unselectEntry() = apply {
+ avatarSelected.performClick()
+ }
+ // endregion
+
+ // region verification
+ fun isSelected() = apply {
+ avatarSelected.assertIsDisplayed().assertIsSelected()
+ }
+
+ fun isNotSelected() = apply {
+ if (avatarSelected.peekIsDisplayed()) {
+ avatarSelected.assertIsNotSelected()
+ avatar.assertDoesNotExist()
+ } else {
+ avatarSelected.assertDoesNotExist()
+ avatar.assertIsDisplayed()
+ }
+ }
+
+ fun hasAvatar(initial: AvatarInitial) = apply {
+ when (initial) {
+ is AvatarInitial.WithText -> avatar.assertTextEquals(initial.text)
+ is AvatarInitial.Draft -> avatarDraft.assertIsDisplayed()
+ }
+ }
+
+ fun hasParticipants(participants: List) = apply {
+ participants.forEachIndexed { index, participant ->
+ val model = ParticipantEntryModel(index, item)
+
+ when (participant) {
+ is ParticipantEntry.NoSender,
+ is ParticipantEntry.NoRecipient -> model.hasNoParticipant(participant.value)
+
+ is ParticipantEntry.WithParticipant -> {
+ model.hasParticipant(participant.value)
+ .isProton(participant.isProton)
+ }
+ }
+ }
+ }
+
+ fun hasLocationIcons(entries: List) = apply {
+ for (entry in entries) {
+ val folderIcon = locations.onChildAt(entry.index)
+ folderIcon.assertTintColor(entry.iconTint)
+ }
+ }
+
+ fun hasNoLocationIcons() = apply {
+ locations.assertDoesNotExist()
+ }
+
+ fun hasSubject(text: String) = apply {
+ subject.assertTextEquals(text)
+ }
+
+ fun hasLabels(entries: List) = apply {
+ for (entry in entries) {
+ val label = labels.onChildAt(entry.index)
+ label.assertTextEquals(entry.name)
+ }
+ }
+
+ fun hasNoLabels() = apply {
+ labels.assertIsNotDisplayed()
+ }
+
+ fun hasDate(text: String) = apply {
+ date.assertTextEquals(text)
+ }
+
+ fun hasCount(text: String) = apply {
+ count.assertTextEquals(text)
+ }
+
+ fun hasNoCount() = apply {
+ count.assertDoesNotExist()
+ }
+
+ fun assertRead() = apply {
+ item.assertItemIsRead(expectedValue = true)
+ }
+
+ fun assertUnread() = apply {
+ item.assertItemIsRead(expectedValue = false)
+ }
+ // endregion
+
+ // region helpers
+ private fun waitForItemToBeShown() = apply {
+ parent
+ .awaitDisplayed(timeout = 30.seconds)
+ .performScrollToNode(itemMatcher)
+ }
+
+ private fun SemanticsNodeInteraction.peekIsDisplayed() = runCatching { assertIsDisplayed() }.isSuccess
+ // endregion
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxType.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxType.kt
new file mode 100644
index 0000000000..8755df1703
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxType.kt
@@ -0,0 +1,37 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.mailbox
+
+import ch.protonmail.android.test.R
+import ch.protonmail.android.uitest.util.getTestString
+
+internal sealed class MailboxType(val name: String) {
+
+ object Inbox : MailboxType(getTestString(R.string.test_label_title_inbox))
+ object Drafts : MailboxType(getTestString(R.string.test_label_title_drafts))
+ object Sent : MailboxType(getTestString(R.string.test_label_title_sent))
+ object Starred : MailboxType(getTestString(R.string.test_label_title_starred))
+ object Archive : MailboxType(getTestString(R.string.test_label_title_archive))
+ object Spam : MailboxType(getTestString(R.string.test_label_title_spam))
+ object Trash : MailboxType(getTestString(R.string.test_label_title_trash))
+ object AllMail : MailboxType(getTestString(R.string.test_label_title_all_mail))
+
+ class CustomLabel(name: String) : MailboxType(name)
+ class CustomFolder(name: String) : MailboxType(name)
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/ParticipantEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/ParticipantEntry.kt
new file mode 100644
index 0000000000..2b08aa8da2
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/ParticipantEntry.kt
@@ -0,0 +1,36 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.mailbox
+
+import ch.protonmail.android.uitest.util.getTestString
+import ch.protonmail.android.test.R as testR
+
+internal sealed class ParticipantEntry(val value: String) {
+
+ class WithParticipant(name: String, val isProton: Boolean = false) : ParticipantEntry(name)
+ object NoSender : ParticipantEntry(getTestString(testR.string.test_mailbox_default_sender))
+ object NoRecipient : ParticipantEntry(getTestString(testR.string.test_mailbox_default_recipient))
+
+ object Common {
+
+ val ProtonOfficial = WithParticipant("Proton", isProton = true)
+ val ProtonUnofficial = WithParticipant("Proton", isProton = false)
+ val FreeUser = WithParticipant("sleepykoala@proton.black")
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/ParticipantEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/ParticipantEntryModel.kt
new file mode 100644
index 0000000000..7fe41054b3
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/ParticipantEntryModel.kt
@@ -0,0 +1,72 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.mailbox
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import ch.protonmail.android.mailcommon.presentation.compose.OfficialBadgeTestTags
+import ch.protonmail.android.mailmailbox.presentation.mailbox.ParticipantsListTestTags
+import ch.protonmail.android.uitest.util.child
+import ch.protonmail.android.uitest.util.children
+import ch.protonmail.android.uitest.util.getTestString
+import ch.protonmail.android.test.R as testR
+
+internal class ParticipantEntryModel(
+ val index: Int,
+ val parent: SemanticsNodeInteraction
+) {
+
+ private val participantRow: SemanticsNodeInteraction by lazy {
+ parent.children { hasTestTag(ParticipantsListTestTags.ParticipantRow) }[index]
+ }
+
+ private val participant: SemanticsNodeInteraction by lazy {
+ participantRow.child { hasTestTag(ParticipantsListTestTags.Participant) }
+ }
+
+ private val badge: SemanticsNodeInteraction by lazy {
+ participantRow.child { hasTestTag(OfficialBadgeTestTags.Item) }
+ }
+
+ // If the model has no participants, the field is a direct child of the parent (has no row as ancestor).
+ private val noParticipant: SemanticsNodeInteraction by lazy {
+ parent.child { hasTestTag(ParticipantsListTestTags.NoParticipant) }
+ }
+
+ fun hasParticipant(value: String) = apply {
+ participant.assertTextEquals(value)
+ noParticipant.assertDoesNotExist()
+ }
+
+ fun hasNoParticipant(value: String) {
+ noParticipant.assertTextEquals(value)
+ participantRow.assertDoesNotExist()
+ }
+
+ fun isProton(value: Boolean) {
+ if (value) {
+ badge.assertIsDisplayed()
+ badge.assertTextEquals(getTestString(testR.string.test_auth_badge_official))
+ } else {
+ badge.assertDoesNotExist()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/snackbar/SnackbarEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/snackbar/SnackbarEntry.kt
new file mode 100644
index 0000000000..8cfb5aadaf
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/snackbar/SnackbarEntry.kt
@@ -0,0 +1,32 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.models.snackbar
+
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+internal abstract class SnackbarEntry(
+ val value: String,
+ val type: SnackbarType,
+ val timeout: Duration = DefaultDuration
+) {
+ companion object {
+ val DefaultDuration = 15.seconds
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/snackbar/SnackbarType.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/snackbar/SnackbarType.kt
new file mode 100644
index 0000000000..db5c8cfe1b
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/snackbar/SnackbarType.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.models.snackbar
+
+internal enum class SnackbarType {
+ Default,
+ Normal,
+ Error,
+ Warning,
+ Success
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/ComposeRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/ComposeRobot.kt
new file mode 100644
index 0000000000..87bc0d06f7
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/ComposeRobot.kt
@@ -0,0 +1,30 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot
+
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import ch.protonmail.android.test.robot.ProtonMailRobot
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+
+/**
+ * A base [ProtonMailRobot] for Compose screens.
+ */
+internal abstract class ComposeRobot(
+ val composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule
+) : ProtonMailRobot
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/ComposeSectionRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/ComposeSectionRobot.kt
new file mode 100644
index 0000000000..f2e5731aa7
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/ComposeSectionRobot.kt
@@ -0,0 +1,30 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot
+
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import ch.protonmail.android.test.robot.ProtonMailSectionRobot
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+
+/**
+ * A base [ProtonMailSectionRobot] for Compose screens.
+ */
+internal abstract class ComposeSectionRobot(
+ val composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule
+) : ProtonMailSectionRobot
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/account/SignOutAccountDialogRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/account/SignOutAccountDialogRobot.kt
new file mode 100644
index 0000000000..3019823436
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/account/SignOutAccountDialogRobot.kt
@@ -0,0 +1,40 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.account
+
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.feature.account.SignOutAccountDialogTestTags
+import ch.protonmail.android.test.ksp.annotations.AsDsl
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.util.awaitHidden
+
+@AsDsl
+internal class SignOutAccountDialogRobot : ComposeRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(SignOutAccountDialogTestTags.RootItem)
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isNotShown() {
+ rootItem.awaitHidden()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/account/section/SignOutAccountDialogButtonsSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/account/section/SignOutAccountDialogButtonsSection.kt
new file mode 100644
index 0000000000..4982964051
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/account/section/SignOutAccountDialogButtonsSection.kt
@@ -0,0 +1,48 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.account.section
+
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onLast
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+import ch.protonmail.android.uitest.robot.account.SignOutAccountDialogRobot
+import ch.protonmail.android.uitest.util.getTestString
+import ch.protonmail.android.test.R as testR
+
+@AttachTo(targets = [SignOutAccountDialogRobot::class], identifier = "buttonsSection")
+internal class SignOutAccountDialogButtonsSection : ComposeSectionRobot() {
+
+ private val signOutButton = composeTestRule.onAllNodesWithText(
+ getTestString(testR.string.test_sign_out_dialog_confirm)
+ ).onLast()
+
+ private val noButton = composeTestRule.onAllNodesWithText(
+ getTestString(testR.string.test_sign_out_dialog_cancel)
+ ).onLast()
+
+ fun tapSignOut() {
+ signOutButton.performClick()
+ }
+
+ fun tapCancel() {
+ noButton.performClick()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/bottombar/BottomBarSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/bottombar/BottomBarSection.kt
new file mode 100644
index 0000000000..62f18ad96e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/bottombar/BottomBarSection.kt
@@ -0,0 +1,63 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.bottombar
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailcommon.presentation.ui.BottomActionBarTestTags
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.models.bottombar.BottomBarActionEntry
+import ch.protonmail.android.uitest.models.bottombar.BottomBarActionEntryModel
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot
+
+@AttachTo(targets = [MailboxRobot::class])
+internal class BottomBarSection : ComposeSectionRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(BottomActionBarTestTags.RootItem)
+
+ fun tapAction(entry: BottomBarActionEntry) = onActionEntryModel(entry.index) {
+ click()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isShown() {
+ rootItem.assertIsDisplayed()
+ }
+
+ fun isNotShown() {
+ rootItem.assertDoesNotExist()
+ }
+
+ fun hasActions(vararg entries: BottomBarActionEntry) {
+ entries.forEach {
+ onActionEntryModel(it.index) {
+ hasDescription(it.description)
+ }
+ }
+ }
+ }
+
+ private fun onActionEntryModel(position: Int, block: BottomBarActionEntryModel.() -> Unit) {
+ block(BottomBarActionEntryModel(position, rootItem))
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/BottomActionBarRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/BottomActionBarRobot.kt
new file mode 100644
index 0000000000..d1e9e32681
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/BottomActionBarRobot.kt
@@ -0,0 +1,72 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.common
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailcommon.domain.model.Action
+import ch.protonmail.android.mailcommon.presentation.R
+import ch.protonmail.android.mailcommon.presentation.model.contentDescriptionRes
+import ch.protonmail.android.mailcommon.presentation.model.descriptionRes
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.util.onNodeWithContentDescription
+import ch.protonmail.android.uitest.util.onNodeWithText
+import me.proton.core.compose.component.PROTON_PROGRESS_TEST_TAG
+
+internal class BottomActionBarRobot : ComposeRobot() {
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun loaderIsDisplayed() {
+ onLoaderNode().assertIsDisplayed()
+ }
+
+ fun failedLoadingErrorIsDisplayed() {
+ onErrorMessageNode().assertIsDisplayed()
+ }
+
+ fun errorAndLoaderHidden() {
+ onLoaderNode().assertDoesNotExist()
+ onErrorMessageNode().assertDoesNotExist()
+ }
+
+ fun actionIsDisplayed(action: Action) {
+ composeTestRule.onNodeWithContentDescription(action.descriptionRes)
+ .assertIsDisplayed()
+ }
+
+ fun actionIsNotDisplayed(action: Action) {
+ composeTestRule.onNodeWithContentDescription(action.contentDescriptionRes)
+ .assertDoesNotExist()
+ }
+
+ private fun onErrorMessageNode() = composeTestRule.onNodeWithText(R.string.common_error_loading_actions)
+
+ private fun onLoaderNode() = composeTestRule.onNodeWithTag(PROTON_PROGRESS_TEST_TAG, useUnmergedTree = true)
+ }
+}
+
+internal fun ComposeContentTestRule.BottomActionBarRobot(content: @Composable () -> Unit): BottomActionBarRobot {
+ setContent(content)
+ return BottomActionBarRobot()
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/FullscreenLoaderSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/FullscreenLoaderSection.kt
new file mode 100644
index 0000000000..e096547387
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/FullscreenLoaderSection.kt
@@ -0,0 +1,36 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.common.section
+
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+import ch.protonmail.android.uitest.robot.composer.ComposerRobot
+import ch.protonmail.android.uitest.util.awaitHidden
+import me.proton.core.compose.component.PROTON_PROGRESS_TEST_TAG
+
+@AttachTo(targets = [ComposerRobot::class])
+internal class FullscreenLoaderSection : ComposeSectionRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(PROTON_PROGRESS_TEST_TAG)
+
+ fun waitUntilGone() {
+ rootItem.awaitHidden()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/KeyboardSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/KeyboardSection.kt
new file mode 100644
index 0000000000..7a20f250ca
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/KeyboardSection.kt
@@ -0,0 +1,50 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.common.section
+
+import androidx.test.espresso.Espresso
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.test.robot.ProtonMailSectionRobot
+import ch.protonmail.android.uitest.robot.composer.ComposerRobot
+import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice
+
+@AttachTo(targets = [ComposerRobot::class])
+internal class KeyboardSection : ProtonMailSectionRobot {
+
+ fun dismissKeyboard() = apply {
+ Espresso.closeSoftKeyboard()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun keyboardIsShown() = assertKeyboardShown(true)
+
+ fun keyboardIsNotShown() = assertKeyboardShown(false)
+
+ private fun assertKeyboardShown(expectedValue: Boolean) = apply {
+ assert(
+ uiDevice
+ .executeShellCommand("dumpsys input_method | grep mInputShown")
+ .contains("mInputShown=$expectedValue")
+ )
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/SnackbarSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/SnackbarSection.kt
new file mode 100644
index 0000000000..a14b261233
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/SnackbarSection.kt
@@ -0,0 +1,98 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.common.section
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onFirst
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailcommon.presentation.ui.CommonTestTags
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.models.snackbar.SnackbarEntry
+import ch.protonmail.android.uitest.models.snackbar.SnackbarType
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+import ch.protonmail.android.uitest.robot.composer.ComposerRobot
+import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot
+import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot
+import ch.protonmail.android.uitest.util.assertions.hasAnyChildWith
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.awaitHidden
+
+@AttachTo(
+ targets = [
+ ComposerRobot::class,
+ ConversationDetailRobot::class,
+ MessageDetailRobot::class,
+ MailboxRobot::class
+ ]
+)
+internal class SnackbarSection : ComposeSectionRobot() {
+
+ // There are different hosts, thus they're defined as lazy to avoid
+ // spending unnecessary time locating unnecessary nodes.
+ private val snackbarHostDefault: SemanticsNodeInteraction by lazy {
+ composeTestRule.onAllNodesWithTag(CommonTestTags.SnackbarHost).onFirst()
+ }
+
+ private val snackbarHostError: SemanticsNodeInteraction by lazy {
+ composeTestRule.onNodeWithTag(CommonTestTags.SnackbarHostError, useUnmergedTree = true)
+ }
+
+ private val snackbarHostWarning: SemanticsNodeInteraction by lazy {
+ composeTestRule.onNodeWithTag(CommonTestTags.SnackbarHostWarning)
+ }
+
+ private val snackbarHostNormal: SemanticsNodeInteraction by lazy {
+ composeTestRule.onNodeWithTag(CommonTestTags.SnackbarHostNormal)
+ }
+
+ private val snackbarHostSuccess: SemanticsNodeInteraction by lazy {
+ composeTestRule.onNodeWithTag(CommonTestTags.SnackbarHostSuccess)
+ }
+
+ fun waitUntilDismisses(entry: SnackbarEntry) {
+ val host = resolveHost(entry)
+ host.awaitHidden()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isDisplaying(entry: SnackbarEntry) = apply {
+ val host = resolveHost(entry)
+
+ // The actual text node is not an immediate child, so the hierarchy needs to be traversed.
+ host.awaitDisplayed(timeout = entry.timeout)
+ .hasAnyChildWith(hasText(entry.value))
+ }
+ }
+
+ private fun resolveHost(entry: SnackbarEntry): SemanticsNodeInteraction {
+ return when (entry.type) {
+ SnackbarType.Default -> snackbarHostDefault
+ SnackbarType.Normal -> snackbarHostNormal
+ SnackbarType.Error -> snackbarHostError
+ SnackbarType.Warning -> snackbarHostWarning
+ SnackbarType.Success -> snackbarHostSuccess
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/ComposerRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/ComposerRobot.kt
new file mode 100644
index 0000000000..25f9d6a1de
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/ComposerRobot.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer
+
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags
+import ch.protonmail.android.test.ksp.annotations.AsDsl
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+
+@AsDsl
+internal class ComposerRobot : ComposeRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(ComposerTestTags.RootItem)
+
+ init {
+ rootItem.awaitDisplayed()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun composerIsShown() = apply {
+ rootItem.assertExists()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerFieldEntryModels.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerFieldEntryModels.kt
new file mode 100644
index 0000000000..1be80159a4
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerFieldEntryModels.kt
@@ -0,0 +1,41 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.model
+
+import androidx.compose.ui.test.hasTestTag
+import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags
+import ch.protonmail.android.uitest.robot.composer.model.ComposerFieldPrefixes.Bcc
+import ch.protonmail.android.uitest.robot.composer.model.ComposerFieldPrefixes.Cc
+import ch.protonmail.android.uitest.robot.composer.model.ComposerFieldPrefixes.To
+
+internal object ToRecipientEntryModel : ComposerRecipientsEntryModel(
+ parentMatcher = hasTestTag(ComposerTestTags.ToRecipient),
+ prefix = To
+)
+
+internal object CcRecipientEntryModel : ComposerRecipientsEntryModel(
+ parentMatcher = hasTestTag(ComposerTestTags.CcRecipient),
+ prefix = Cc
+)
+
+internal object BccRecipientEntryModel : ComposerRecipientsEntryModel(
+ parentMatcher = hasTestTag(ComposerTestTags.BccRecipient),
+ prefix = Bcc
+)
+
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerFieldPrefixes.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerFieldPrefixes.kt
new file mode 100644
index 0000000000..ad7153156e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerFieldPrefixes.kt
@@ -0,0 +1,33 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.model
+
+import ch.protonmail.android.uitest.util.getTestString
+import ch.protonmail.android.test.R as testR
+
+internal object ComposerFieldPrefixes {
+
+ val From = Prefix(getTestString(testR.string.test_composer_sender_label))
+ val To = Prefix(getTestString(testR.string.test_composer_to_recipient_label))
+ val Cc = Prefix(getTestString(testR.string.test_composer_cc_recipient_label))
+ val Bcc = Prefix(getTestString(testR.string.test_composer_bcc_recipient_label))
+}
+
+@JvmInline
+internal value class Prefix(val value: String)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerRecipientsEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerRecipientsEntryModel.kt
new file mode 100644
index 0000000000..8eb3ba6a4a
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerRecipientsEntryModel.kt
@@ -0,0 +1,146 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.model
+
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assertIsFocused
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasAnyAncestor
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performImeAction
+import androidx.compose.ui.test.performKeyInput
+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.uitest.robot.composer.model.chips.RecipientChipValidationState.Invalid
+import ch.protonmail.android.uitest.util.assertions.assertEmptyText
+import ch.protonmail.android.uitest.util.awaitHidden
+
+internal sealed class ComposerRecipientsEntryModel(
+ private val parentMatcher: SemanticsMatcher,
+ private val prefix: Prefix,
+ composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule
+) {
+
+ private val parent = composeTestRule.onNode(parentMatcher, useUnmergedTree = true)
+
+ private val prefixField = composeTestRule.onNode(
+ matcher = hasTestTag(ComposerTestTags.FieldPrefix) and hasAnyAncestor(parentMatcher),
+ useUnmergedTree = true
+ )
+
+ private val textField = composeTestRule.onNode(
+ // use hasAnyAncestor as the TextField is not a direct child of the parent.
+ matcher = hasTestTag(ChipsTestTags.BasicTextField) and hasAnyAncestor(parentMatcher)
+ )
+
+ // region actions
+ fun typeValue(value: String) = withParentFocused {
+ textField.performTextInput(value)
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ fun tapKey(key: Key) {
+ textField.performKeyInput { this.pressKey(key) }
+ }
+
+ fun performImeAction() {
+ textField.performImeAction()
+ }
+
+ fun focus() {
+ parent.performClick()
+ }
+
+ fun tapChipDeletionIconAt(position: Int) {
+ val model = RecipientChipEntryModel(position, parentMatcher)
+ model.tapDeleteIcon()
+ }
+ // endregion
+
+ // region verification
+ fun isHidden() {
+ parent.awaitHidden().assertDoesNotExist()
+ }
+
+ fun isFocused() = apply {
+ textField.assertIsFocused()
+ }
+
+ fun hasEmptyValue() = withParentFocused {
+ hasPrefix()
+ textField.assertEmptyText()
+
+ // Check that chip at index 0 does not exist, as recomposition will always populate index 0 if there's a chip.
+ RecipientChipEntryModel(0, parentMatcher).doesNotExist()
+ }
+
+ fun hasValue(value: String) = withParentFocused {
+ hasPrefix()
+ textField.assertTextEquals(value)
+ }
+
+ fun hasChips(vararg chips: RecipientChipEntry) {
+ for (chip in chips) {
+ val model = RecipientChipEntryModel(chip.index, parentMatcher)
+ model
+ .hasText(chip.text)
+ .hasEmailValidationState(chip.state)
+ .also {
+ if (chip.hasDeleteIcon) model.hasDeleteIcon() else model.hasNoDeleteIcon()
+ }
+ .also {
+ if (chip.state is Invalid) model.hasErrorIcon() else model.hasNoErrorIcon()
+ }
+ }
+ }
+
+ fun hasNoChip(chip: RecipientChipEntry) {
+ val model = RecipientChipEntryModel(chip.index, parentMatcher)
+ model.doesNotExist()
+ }
+
+ private fun hasPrefix() = apply {
+ prefixField.assertTextEquals(prefix.value)
+ }
+ // endregion
+
+ // region utility methods
+ private fun withParentFocused(block: ComposerRecipientsEntryModel.() -> Unit) = apply {
+ // This is needed as even though the correct textField is located, the automation might fill the wrong field.
+ parent.performScrollTo()
+
+ if (!peekIsFocused()) {
+ parent.performClick()
+ }
+ block()
+ }
+
+ private fun peekIsFocused(): Boolean = runCatching { isFocused() }.isSuccess
+ // endregion
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/ChipsCreationTrigger.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/ChipsCreationTrigger.kt
new file mode 100644
index 0000000000..1fd4b2806f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/ChipsCreationTrigger.kt
@@ -0,0 +1,24 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.model.chips
+
+internal enum class ChipsCreationTrigger {
+ ImeAction,
+ NewLine
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipEntry.kt
new file mode 100644
index 0000000000..82ee593f29
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipEntry.kt
@@ -0,0 +1,26 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.model.chips
+
+internal data class RecipientChipEntry(
+ val index: Int,
+ val text: String,
+ val hasDeleteIcon: Boolean = false,
+ val state: RecipientChipValidationState
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipEntryModel.kt
new file mode 100644
index 0000000000..15e9269dec
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipEntryModel.kt
@@ -0,0 +1,99 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.model.chips
+
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasAnyAncestor
+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.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
+import org.junit.Assert.assertEquals
+
+internal class RecipientChipEntryModel(
+ index: Int,
+ parentMatcher: SemanticsMatcher,
+ composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule
+) {
+
+ private val parent = composeTestRule.onNode(
+ matcher = hasTestTag("${ChipsTestTags.InputChip}$index") and hasAnyAncestor(parentMatcher),
+ useUnmergedTree = true
+ )
+ private val text = parent.child { hasTestTag(ChipsTestTags.InputChipText) }
+ private val errorIcon = parent.child { hasTestTag(ChipsTestTags.InputChipLeadingIcon) }
+ private val deleteIcon = parent.child { hasTestTag(ChipsTestTags.InputChipTrailingIcon) }
+
+ // region actions
+ fun tapDeleteIcon() = withParentDisplayed {
+ deleteIcon.performScrollTo().performClick()
+ }
+ // endregion
+
+ // region verification
+ fun hasText(value: String): RecipientChipEntryModel = withParentDisplayed {
+ text.assertTextEquals(value)
+ }
+
+ fun hasEmailValidationState(state: RecipientChipValidationState) = withParentDisplayed {
+ parent.assertFieldState(state.value)
+ }
+
+ fun hasDeleteIcon() = withParentDisplayed {
+ deleteIcon.performScrollTo().assertExists()
+ }
+
+ fun hasNoDeleteIcon() = withParentDisplayed {
+ deleteIcon.assertDoesNotExist()
+ }
+
+ fun hasErrorIcon() = withParentDisplayed {
+ errorIcon.assertExists()
+ }
+
+ fun hasNoErrorIcon() = withParentDisplayed {
+ errorIcon.assertDoesNotExist()
+ }
+
+ fun doesNotExist() {
+ parent.assertDoesNotExist()
+ }
+ // endregion
+
+ // On some configurations, the transition from raw text to chip might take a bit and make the test fail.
+ private fun withParentDisplayed(block: RecipientChipEntryModel.() -> Unit) = apply {
+ parent.performScrollTo()
+ block()
+ }
+
+ private fun SemanticsNodeInteraction.assertFieldState(isValid: Boolean) = apply {
+ val isValidProperty = requireNotNull(getKeyValueByName(CustomSemanticsPropertyKeyNames.IsValidFieldKey)) {
+ "IsValidFieldKey property was not found on this node. Did you forget to set it?"
+ }
+
+ assertEquals(isValid, isValidProperty.value)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipValidationState.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipValidationState.kt
new file mode 100644
index 0000000000..dd2b915849
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipValidationState.kt
@@ -0,0 +1,24 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.model.chips
+
+internal sealed class RecipientChipValidationState(val value: Boolean) {
+ data object Valid : RecipientChipValidationState(true)
+ data object Invalid : RecipientChipValidationState(false)
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/sender/ChangeSenderEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/sender/ChangeSenderEntry.kt
new file mode 100644
index 0000000000..a564e17d6a
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/sender/ChangeSenderEntry.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.model.sender
+
+internal data class ChangeSenderEntry(
+ val index: Int,
+ val address: String,
+ val isEnabled: Boolean = true
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/sender/ChangeSenderEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/sender/ChangeSenderEntryModel.kt
new file mode 100644
index 0000000000..7af2614290
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/sender/ChangeSenderEntryModel.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.model.sender
+
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailcomposer.presentation.ui.ChangeSenderBottomSheetTestTags
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.awaitHidden
+
+internal class ChangeSenderEntryModel(index: Int, composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule) {
+
+ private val item = composeTestRule.onNodeWithTag("${ChangeSenderBottomSheetTestTags.Item}$index")
+
+ // region action
+ fun selectSender() {
+ item.awaitDisplayed().performClick().awaitHidden()
+ }
+ // endregion
+
+ // region verification
+ fun doesNotExist() {
+ item.assertDoesNotExist()
+ }
+
+ fun hasText(value: String) {
+ item.awaitDisplayed().assertTextEquals(value)
+ }
+ // endregion
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/snackbar/ComposerSnackbar.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/snackbar/ComposerSnackbar.kt
new file mode 100644
index 0000000000..d0b3444a68
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/snackbar/ComposerSnackbar.kt
@@ -0,0 +1,70 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.model.snackbar
+
+import ch.protonmail.android.test.R
+import ch.protonmail.android.uitest.models.snackbar.SnackbarEntry
+import ch.protonmail.android.uitest.models.snackbar.SnackbarType
+import ch.protonmail.android.uitest.util.getTestString
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+internal sealed class ComposerSnackbar(
+ value: String,
+ type: SnackbarType,
+ duration: Duration = DefaultDuration
+) : SnackbarEntry(value, type, duration) {
+
+ data object AttachmentUploadError : ComposerSnackbar(
+ getTestString(R.string.test_mailbox_attachment_uploading_error), SnackbarType.Error
+ )
+
+ data object DraftSaved : ComposerSnackbar(
+ getTestString(R.string.test_mailbox_draft_saved), SnackbarType.Success
+ )
+
+ data object DraftOutOfSync : ComposerSnackbar(
+ getTestString(R.string.test_composer_error_loading_draft), SnackbarType.Default
+ )
+
+ data object MessageSent : ComposerSnackbar(
+ getTestString(R.string.test_mailbox_message_sending_success), SnackbarType.Success, duration = SendingTimeout
+ )
+
+ data object MessageSentError : ComposerSnackbar(
+ getTestString(R.string.test_mailbox_message_sending_error), SnackbarType.Error, duration = SendingTimeout
+ )
+
+ data object MessageQueued : ComposerSnackbar(
+ getTestString(R.string.test_mailbox_message_sending_offline), SnackbarType.Normal
+ )
+
+ data object SendingMessage : ComposerSnackbar(
+ getTestString(R.string.test_mailbox_message_sending), SnackbarType.Normal
+ )
+
+ data object UpgradePlanToChangeSender : ComposerSnackbar(
+ getTestString(R.string.test_composer_change_sender_paid_feature), SnackbarType.Default
+ )
+
+ companion object {
+ // This is required as SendMessageWorker could be delayed by 30s due to pending UploadDraftWorker.
+ private val SendingTimeout = 60.seconds
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ChangeSenderBottomSheetSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ChangeSenderBottomSheetSection.kt
new file mode 100644
index 0000000000..46bcac2c31
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ChangeSenderBottomSheetSection.kt
@@ -0,0 +1,73 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.section
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeDown
+import ch.protonmail.android.mailcomposer.presentation.ui.ChangeSenderBottomSheetTestTags
+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.robot.composer.model.sender.ChangeSenderEntry
+import ch.protonmail.android.uitest.robot.composer.model.sender.ChangeSenderEntryModel
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.awaitHidden
+
+@AttachTo(targets = [ComposerRobot::class], identifier = "changeSenderBottomSheet")
+internal class ChangeSenderBottomSheetSection : ComposeSectionRobot() {
+
+ private val parent = composeTestRule.onNodeWithTag(ChangeSenderBottomSheetTestTags.Root)
+
+ fun tapEntryAt(position: Int) {
+ val model = ChangeSenderEntryModel(position)
+ model.selectSender()
+ }
+
+ fun dismiss() {
+ parent.performTouchInput { swipeDown() }
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isShown() {
+ parent.awaitDisplayed().assertIsDisplayed()
+ }
+
+ fun isHidden() {
+ parent.awaitHidden().assertIsNotDisplayed()
+ }
+
+ fun hasEntries(vararg entries: ChangeSenderEntry) {
+ for (entry in entries) {
+ val model = ChangeSenderEntryModel(entry.index)
+
+ if (entry.isEnabled) {
+ model.hasText(entry.address)
+ } else {
+ model.doesNotExist()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerAlertDialogSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerAlertDialogSection.kt
new file mode 100644
index 0000000000..7ef7a8b614
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerAlertDialogSection.kt
@@ -0,0 +1,54 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.section
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags
+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
+
+@AttachTo(targets = [ComposerRobot::class], identifier = "composerAlertDialogSection")
+internal class ComposerAlertDialogSection : ComposeSectionRobot() {
+
+ private val sendWithEmptySubjectDialog = composeTestRule.onNodeWithTag(
+ ComposerTestTags.SendWithEmptySubjectDialog
+ )
+
+ private val sendWithEmptySubjectDialogDismissButton = composeTestRule.onNodeWithTag(
+ ComposerTestTags.SendWithEmptySubjectDialogDismiss
+ )
+ private val sendWithEmptySubjectDialogConfirmButton = composeTestRule.onNodeWithTag(
+ ComposerTestTags.SendWithEmptySubjectDialogConfirm
+ )
+
+ fun clickSendWithEmptySubjectDialogDismissButton() = sendWithEmptySubjectDialogDismissButton.performClick()
+ fun clickSendWithEmptySubjectDialogConfirmButton() = sendWithEmptySubjectDialogConfirmButton.performClick()
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isSendWithEmptySubjectDialogDisplayed() = sendWithEmptySubjectDialog.assertIsDisplayed()
+ fun isSendWithEmptySubjectDialogDismissed() = sendWithEmptySubjectDialog.assertDoesNotExist()
+
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerMessageBodySection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerMessageBodySection.kt
new file mode 100644
index 0000000000..7423552b6b
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerMessageBodySection.kt
@@ -0,0 +1,66 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.section
+
+import androidx.compose.ui.test.assertIsFocused
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTextInput
+import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags
+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.getTestString
+import ch.protonmail.android.test.R as testR
+
+@AttachTo(targets = [ComposerRobot::class], identifier = "messageBodySection")
+internal class ComposerMessageBodySection : ComposeSectionRobot() {
+
+ private val messageBodyText = composeTestRule.onNodeWithTag(
+ testTag = ComposerTestTags.MessageBody,
+ useUnmergedTree = true
+ )
+
+ private val messageBodyPlaceholder = composeTestRule.onNodeWithTag(
+ testTag = ComposerTestTags.MessageBodyPlaceholder,
+ useUnmergedTree = true
+ )
+
+ fun typeMessageBody(value: String) = apply {
+ messageBodyText.performTextInput(value)
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun hasPlaceholderText() = apply {
+ messageBodyPlaceholder.assertTextEquals(getTestString(testR.string.test_composer_body_placeholder))
+ }
+
+ fun hasText(value: String) = apply {
+ messageBodyPlaceholder.assertDoesNotExist()
+ messageBodyText.assertTextEquals(value)
+ }
+
+ fun hasFocus() = apply {
+ messageBodyText.assertIsFocused()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerSenderSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerSenderSection.kt
new file mode 100644
index 0000000000..7587428655
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerSenderSection.kt
@@ -0,0 +1,61 @@
+/*
+ * 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 .
+ */
+
+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 androidx.compose.ui.test.performScrollTo
+import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags
+import ch.protonmail.android.mailcomposer.presentation.ui.PrefixedEmailSelectorTestTags
+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.robot.composer.model.ComposerFieldPrefixes
+import ch.protonmail.android.uitest.util.assertions.assertEditableTextEquals
+import ch.protonmail.android.uitest.util.assertions.assertNotEditableTextEquals
+import ch.protonmail.android.uitest.util.child
+
+@AttachTo(targets = [ComposerRobot::class], identifier = "senderSection")
+internal class ComposerSenderSection : ComposeSectionRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(ComposerTestTags.ComposerForm)
+ private val parent = rootItem.child { hasTestTag(ComposerTestTags.FromSender) }
+
+ private val text = parent.child {
+ hasTestTag(PrefixedEmailSelectorTestTags.TextField)
+ }
+ private val changeSenderButton = parent.child {
+ hasTestTag(ComposerTestTags.ChangeSenderButton)
+ }
+
+ fun tapChangeSender() {
+ changeSenderButton.performScrollTo().performClick()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun hasValue(value: String) = text.performScrollTo().run {
+ assertNotEditableTextEquals(ComposerFieldPrefixes.From.value)
+ assertEditableTextEquals(value)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerSubjectSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerSubjectSection.kt
new file mode 100644
index 0000000000..623cf5ec44
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerSubjectSection.kt
@@ -0,0 +1,90 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.section
+
+import androidx.compose.ui.test.assertIsFocused
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performImeAction
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performTextClearance
+import androidx.compose.ui.test.performTextInput
+import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags
+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.assertions.assertEmptyText
+import ch.protonmail.android.uitest.util.getTestString
+import ch.protonmail.android.test.R as testR
+
+@AttachTo(targets = [ComposerRobot::class], identifier = "subjectSection")
+internal class ComposerSubjectSection : ComposeSectionRobot() {
+
+ private val subject = composeTestRule.onNodeWithTag(ComposerTestTags.Subject, useUnmergedTree = true)
+ private val subjectPlaceholder = composeTestRule.onNodeWithTag(
+ testTag = ComposerTestTags.SubjectPlaceholder,
+ useUnmergedTree = true
+ )
+
+ fun focusField() = withSubjectDisplayed {
+ subject.performClick()
+ }
+
+ fun typeSubject(value: String) = withSubjectDisplayed {
+ subject.performTextInput(value)
+ }
+
+ fun clearField() = withSubjectDisplayed {
+ subject.performTextClearance()
+ }
+
+ fun performImeAction() = withSubjectDisplayed {
+ subject.performImeAction()
+ }
+
+ private fun withSubjectDisplayed(block: ComposerSubjectSection.() -> Unit) = apply {
+ subject.performScrollTo()
+ block()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun hasEmptySubject() {
+ subject.assertEmptyText()
+ subjectPlaceholder.assertTextEquals(SubjectPlaceholder)
+ }
+
+ fun hasSubject(value: String) {
+ subject.assertTextEquals(value)
+ subjectPlaceholder.assertDoesNotExist()
+ }
+
+ fun hasFocus() {
+ subject.assertIsFocused()
+ }
+ }
+
+ private companion object {
+
+ val SubjectPlaceholder = getTestString(testR.string.test_composer_subject_placeholder)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerTopBarAppSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerTopBarAppSection.kt
new file mode 100644
index 0000000000..8158b5a720
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerTopBarAppSection.kt
@@ -0,0 +1,82 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.section
+
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsNotEnabled
+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.ComposerTestTags
+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.awaitHidden
+import ch.protonmail.android.uitest.util.child
+
+@AttachTo(
+ targets = [
+ ComposerRobot::class
+ ],
+ identifier = "topAppBarSection"
+)
+internal class ComposerTopBarAppSection : ComposeSectionRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(
+ ComposerTestTags.TopAppBar
+ )
+
+ private val closeButton = rootItem.child {
+ hasTestTag(ComposerTestTags.CloseButton)
+ }
+
+ private val attachmentsButton = rootItem.child {
+ hasTestTag(ComposerTestTags.AttachmentsButton)
+ }
+
+ private val sendButton = rootItem.child {
+ hasTestTag(ComposerTestTags.SendButton)
+ }
+
+ fun tapCloseButton() = apply {
+ closeButton.performClick()
+ rootItem.awaitHidden()
+ }
+
+ fun tapAttachmentsButton() = apply {
+ attachmentsButton.performClick()
+ }
+
+ fun tapSendButton() = apply {
+ sendButton.performClick()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isSendButtonEnabled() = apply {
+ sendButton.assertIsEnabled()
+ }
+
+ fun isSendButtonDisabled() = apply {
+ sendButton.assertIsNotEnabled()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsBccSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsBccSection.kt
new file mode 100644
index 0000000000..41a50761dd
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsBccSection.kt
@@ -0,0 +1,28 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.section.recipients
+
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.uitest.robot.composer.ComposerRobot
+import ch.protonmail.android.uitest.robot.composer.model.BccRecipientEntryModel
+
+@AttachTo(targets = [ComposerRobot::class], identifier = "bccRecipientSection")
+internal class ComposerRecipientsBccSection : ComposerRecipientsSection(
+ entryModel = BccRecipientEntryModel
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsCcSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsCcSection.kt
new file mode 100644
index 0000000000..3b964a4294
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsCcSection.kt
@@ -0,0 +1,28 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.section.recipients
+
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.uitest.robot.composer.ComposerRobot
+import ch.protonmail.android.uitest.robot.composer.model.CcRecipientEntryModel
+
+@AttachTo(targets = [ComposerRobot::class], identifier = "ccRecipientSection")
+internal class ComposerRecipientsCcSection : ComposerRecipientsSection(
+ entryModel = CcRecipientEntryModel
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsSection.kt
new file mode 100644
index 0000000000..1a8ca4fbe4
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsSection.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.section.recipients
+
+import android.view.KeyEvent
+import androidx.compose.ui.input.key.Key
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+import ch.protonmail.android.uitest.robot.composer.model.ComposerRecipientsEntryModel
+import ch.protonmail.android.uitest.robot.composer.model.chips.ChipsCreationTrigger
+import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry
+
+internal abstract class ComposerRecipientsSection(
+ private val entryModel: ComposerRecipientsEntryModel
+) : ComposeSectionRobot() {
+
+ fun focusField() {
+ entryModel.focus()
+ }
+
+ fun typeRecipient(value: String, autoConfirm: Boolean = false) = apply {
+ entryModel.typeValue(value)
+ if (autoConfirm) triggerChipCreation(ChipsCreationTrigger.NewLine)
+ }
+
+ fun typeMultipleRecipients(vararg values: String) {
+ values.forEach {
+ typeRecipient(it)
+ triggerChipCreation(ChipsCreationTrigger.NewLine)
+ }
+ }
+
+ fun tapRecipientField() = apply {
+ entryModel.focus()
+ }
+
+ fun triggerChipCreation(trigger: ChipsCreationTrigger = ChipsCreationTrigger.ImeAction) = apply {
+ when (trigger) {
+ ChipsCreationTrigger.ImeAction -> tapImeAction()
+ ChipsCreationTrigger.NewLine -> typeNewLine()
+ }
+ }
+
+ fun deleteChipAt(position: Int) = apply {
+ entryModel.tapChipDeletionIconAt(position)
+ }
+
+ fun tapBackspace() = apply {
+ entryModel.tapKey(Key.Backspace)
+ }
+
+ private fun typeNewLine() = apply {
+ entryModel.tapKey(Key(KeyEvent.KEYCODE_ENTER))
+ }
+
+ private fun tapImeAction() = apply {
+ entryModel.performImeAction()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isFieldFocused() = apply {
+ entryModel.isFocused()
+ }
+
+ fun isHidden() = apply {
+ entryModel.isHidden()
+ }
+
+ fun isEmptyField() = apply {
+ entryModel.hasEmptyValue()
+ }
+
+ fun hasRecipient(value: String) = apply {
+ entryModel.hasValue(value)
+ }
+
+ fun hasRecipientChips(vararg chips: RecipientChipEntry) = apply {
+ entryModel.hasChips(*chips)
+ }
+
+ fun recipientChipIsNotDisplayed(chip: RecipientChipEntry) = apply {
+ entryModel.hasNoChip(chip)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsToSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsToSection.kt
new file mode 100644
index 0000000000..77e258ee74
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsToSection.kt
@@ -0,0 +1,55 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.composer.section.recipients
+
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.uitest.robot.composer.ComposerRobot
+import ch.protonmail.android.uitest.robot.composer.model.ToRecipientEntryModel
+
+@AttachTo(targets = [ComposerRobot::class], identifier = "toRecipientSection")
+internal class ComposerRecipientsToSection : ComposerRecipientsSection(
+ entryModel = ToRecipientEntryModel
+) {
+
+ private val expandRecipientsButton = composeTestRule.onNode(
+ matcher = hasTestTag(ComposerTestTags.ExpandCollapseArrow),
+ useUnmergedTree = true
+ )
+
+ private val hideRecipientsButton = composeTestRule.onNode(
+ matcher = hasTestTag(ComposerTestTags.CollapseExpandArrow),
+ useUnmergedTree = true
+ )
+
+ fun chevronNotVisible() = apply {
+ expandRecipientsButton.assertDoesNotExist()
+ }
+
+ fun expandCcAndBccFields() = apply {
+ expandRecipientsButton.performScrollTo().performClick()
+ }
+
+ fun hideCcAndBccFields() = apply {
+ hideRecipientsButton.performScrollTo().performClick()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/ConversationDetailRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/ConversationDetailRobot.kt
new file mode 100644
index 0000000000..bfd7ac1611
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/ConversationDetailRobot.kt
@@ -0,0 +1,41 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail
+
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreenTestTags
+import ch.protonmail.android.test.ksp.annotations.AsDsl
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+
+@AsDsl
+internal class ConversationDetailRobot : ComposeRobot() {
+
+ @VerifiesOuter
+ inner class Verify : ComposeSectionRobot() {
+
+ fun conversationDetailScreenIsShown() {
+ composeTestRule.onNodeWithTag(ConversationDetailScreenTestTags.RootItem)
+ .awaitDisplayed()
+ .assertExists()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/MessageDetailRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/MessageDetailRobot.kt
new file mode 100644
index 0000000000..b2c9caf006
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/MessageDetailRobot.kt
@@ -0,0 +1,47 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.maildetail.presentation.ui.MessageDetailScreenTestTags
+import ch.protonmail.android.test.ksp.annotations.AsDsl
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+
+@AsDsl
+internal class MessageDetailRobot : ComposeRobot() {
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun messageDetailScreenIsShown() {
+ composeTestRule.onNodeWithTag(MessageDetailScreenTestTags.RootItem)
+ .awaitDisplayed()
+ .assertExists()
+ }
+ }
+}
+
+internal fun ComposeContentTestRule.MessageDetailRobot(content: @Composable () -> Unit): MessageDetailRobot {
+ setContent(content)
+ return MessageDetailRobot()
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/DetailScreenTopBarEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/DetailScreenTopBarEntryModel.kt
new file mode 100644
index 0000000000..e69cffb22f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/DetailScreenTopBarEntryModel.kt
@@ -0,0 +1,58 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.model
+
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.onChild
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.maildetail.presentation.ui.DetailScreenTopBarTestTags
+import ch.protonmail.android.uitest.util.child
+
+internal class DetailScreenTopBarEntryModel(composeTestRule: ComposeTestRule) {
+
+ private val rootItem = composeTestRule
+ .onNodeWithTag(
+ testTag = DetailScreenTopBarTestTags.RootItem,
+ useUnmergedTree = true
+ )
+
+ private val backButton = composeTestRule.onNodeWithTag(
+ testTag = DetailScreenTopBarTestTags.BackButton,
+ useUnmergedTree = true
+ )
+
+ private val subject = rootItem.child {
+ hasTestTag(DetailScreenTopBarTestTags.Subject)
+ }
+
+ // region actions
+ fun tapBack() {
+ backButton.performClick()
+ }
+ // endregion
+
+ // region verification
+ fun hasSubject(value: String) = apply {
+ subject.onChild().assertTextEquals(value)
+ }
+ // endregion
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/MessageDetailSnackbar.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/MessageDetailSnackbar.kt
new file mode 100644
index 0000000000..7d217618a6
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/MessageDetailSnackbar.kt
@@ -0,0 +1,47 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.model
+
+import ch.protonmail.android.test.R
+import ch.protonmail.android.uitest.models.snackbar.SnackbarEntry
+import ch.protonmail.android.uitest.models.snackbar.SnackbarType
+import ch.protonmail.android.uitest.util.getTestString
+
+internal sealed class MessageDetailSnackbar(value: String, type: SnackbarType) : SnackbarEntry(value, type) {
+
+ class ConversationMovedToFolder(folder: String) : MessageDetailSnackbar(
+ getTestString(R.string.test_conversation_moved_to_selected_destination, folder), SnackbarType.Normal
+ )
+
+ object FailedToDecryptMessage : MessageDetailSnackbar(
+ getTestString(R.string.test_decryption_error), SnackbarType.Default
+ )
+
+ object FailedToGetAttachment : MessageDetailSnackbar(
+ getTestString(R.string.error_get_attachment_failed), SnackbarType.Default
+ )
+
+ object FailedToLoadMessage : MessageDetailSnackbar(
+ getTestString(R.string.test_detail_error_retrieving_message_body), SnackbarType.Default
+ )
+
+ object MultipleDownloadsWarning : MessageDetailSnackbar(
+ getTestString(R.string.test_error_attachment_download_in_progress), SnackbarType.Default
+ )
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailItemEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailItemEntry.kt
new file mode 100644
index 0000000000..10cd426018
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailItemEntry.kt
@@ -0,0 +1,26 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.model.attachments
+
+internal data class AttachmentDetailItemEntry(
+ val index: Int,
+ val fileName: String,
+ val fileSize: String,
+ val hasDeleteIcon: Boolean = false
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailItemEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailItemEntryModel.kt
new file mode 100644
index 0000000000..e21594a05e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailItemEntryModel.kt
@@ -0,0 +1,107 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.model.attachments
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailmessage.presentation.ui.AttachmentFooterTestTags
+import ch.protonmail.android.mailmessage.presentation.ui.AttachmentItemTestTags
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.awaitHidden
+import ch.protonmail.android.uitest.util.child
+
+internal class AttachmentDetailItemEntryModel(index: Int, parent: SemanticsNodeInteraction) {
+
+ private val item = parent.child {
+ hasTestTag("${AttachmentFooterTestTags.Item}$index")
+ }
+
+ private val icon = item.child {
+ hasTestTag(AttachmentItemTestTags.Icon)
+ }
+
+ private val loader = item.child {
+ hasTestTag(AttachmentItemTestTags.Loader)
+ }
+
+ private val name = item.child {
+ hasTestTag(AttachmentItemTestTags.Name)
+ }
+
+ private val extension = item.child {
+ hasTestTag(AttachmentItemTestTags.Extension)
+ }
+
+ private val deleteIcon = item.child {
+ hasTestTag(AttachmentItemTestTags.Delete)
+ }
+
+ private val size = item.child {
+ hasTestTag(AttachmentItemTestTags.Size)
+ }
+
+ // region actions
+ fun tapItem() {
+ name.performClick()
+ }
+ // endregion
+
+ // region verification
+ fun hasIcon(): AttachmentDetailItemEntryModel = apply {
+ icon.assertIsDisplayed()
+ loader.assertDoesNotExist()
+ }
+
+ fun hasLoaderIcon(): AttachmentDetailItemEntryModel = apply {
+ icon.awaitHidden().assertDoesNotExist()
+ loader.awaitDisplayed().assertIsDisplayed()
+ }
+
+ fun hasNoLoaderIcon(): AttachmentDetailItemEntryModel = apply {
+ loader.awaitHidden().assertDoesNotExist()
+ }
+
+ fun hasName(value: String): AttachmentDetailItemEntryModel = apply {
+ val (fileName, fileExtension) = value.split(".")
+ name.assertTextEquals(fileName)
+ extension.assertTextEquals(".$fileExtension")
+ }
+
+ fun hasDeleteIcon(): AttachmentDetailItemEntryModel = apply {
+ deleteIcon.assertIsDisplayed()
+ }
+
+ fun hasNoDeleteIcon(): AttachmentDetailItemEntryModel = apply {
+ deleteIcon.assertDoesNotExist()
+ }
+
+ fun hasSize(value: String): AttachmentDetailItemEntryModel = apply {
+ size.assertTextEquals(value)
+ }
+ // endregion
+
+ // region utility
+ fun waitUntilShown() = apply {
+ item.awaitDisplayed()
+ }
+ // endregion
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailSummaryEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailSummaryEntry.kt
new file mode 100644
index 0000000000..2187defd88
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailSummaryEntry.kt
@@ -0,0 +1,24 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.model.attachments
+
+internal data class AttachmentDetailSummaryEntry(
+ val summary: String,
+ val size: String
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/bottomsheet/MoveToBottomSheetFolderEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/bottomsheet/MoveToBottomSheetFolderEntry.kt
new file mode 100644
index 0000000000..47e1957c2b
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/bottomsheet/MoveToBottomSheetFolderEntry.kt
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.model.bottomsheet
+
+import ch.protonmail.android.uitest.models.folders.Tint
+import ch.protonmail.android.uitest.util.getTestString
+import ch.protonmail.android.test.R as testR
+
+internal data class MoveToBottomSheetFolderEntry(
+ val index: Int,
+ val name: String,
+ val iconTint: Tint = Tint.NoColor,
+ val isSelected: Boolean = false
+) {
+
+ object SystemFolders {
+
+ val Inbox = MoveToBottomSheetFolderEntry(index = 0, name = getTestString(testR.string.label_title_inbox))
+ val Archive = MoveToBottomSheetFolderEntry(index = 1, name = getTestString(testR.string.label_title_archive))
+ val Spam = MoveToBottomSheetFolderEntry(index = 2, name = getTestString(testR.string.label_title_spam))
+ val Trash = MoveToBottomSheetFolderEntry(index = 3, name = getTestString(testR.string.label_title_trash))
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/bottomsheet/MoveToBottomSheetFolderEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/bottomsheet/MoveToBottomSheetFolderEntryModel.kt
new file mode 100644
index 0000000000..3624deb350
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/bottomsheet/MoveToBottomSheetFolderEntryModel.kt
@@ -0,0 +1,91 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.model.bottomsheet
+
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasParent
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailmessage.presentation.ui.bottomsheet.MoveToBottomSheetTestTags
+import ch.protonmail.android.uitest.models.folders.Tint
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+import ch.protonmail.android.uitest.util.assertions.assertTintColor
+import ch.protonmail.android.uitest.util.child
+
+internal class MoveToBottomSheetFolderEntryModel private constructor(
+ matcher: SemanticsMatcher,
+ index: Int,
+ composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule
+) {
+
+ private val rootItem = composeTestRule.onAllNodes(matcher, useUnmergedTree = true)[index]
+
+ private val icon = rootItem.child {
+ hasTestTag(MoveToBottomSheetTestTags.FolderIcon)
+ }
+
+ private val text = rootItem.child {
+ hasTestTag(MoveToBottomSheetTestTags.FolderNameText)
+ }
+
+ private val selectionIcon = rootItem.child {
+ hasTestTag(MoveToBottomSheetTestTags.FolderSelectionIcon)
+ }
+
+ // region actions
+ fun click() {
+ rootItem.performClick()
+ }
+ // endregion
+
+ // region verification
+ fun hasIcon(tint: Tint): MoveToBottomSheetFolderEntryModel = apply {
+ icon.assertExists()
+ icon.assertTintColor(tint)
+ }
+
+ fun hasText(value: String): MoveToBottomSheetFolderEntryModel = apply {
+ text.assertTextEquals(value)
+ }
+
+ fun hasSelectionIcon(): MoveToBottomSheetFolderEntryModel = apply {
+ selectionIcon.assertExists()
+ }
+
+ fun hasNoSelectionIcon(): MoveToBottomSheetFolderEntryModel = apply {
+ selectionIcon.assertDoesNotExist()
+ }
+ // endregion
+
+ companion object {
+
+ operator fun invoke(index: Int): MoveToBottomSheetFolderEntryModel {
+ val matcher = hasTestTag(MoveToBottomSheetTestTags.FolderItem)
+ return MoveToBottomSheetFolderEntryModel(matcher, index)
+ }
+
+ operator fun invoke(folderName: String): MoveToBottomSheetFolderEntryModel {
+ val matcher = hasText(folderName) and hasParent(hasTestTag(MoveToBottomSheetTestTags.FolderItem))
+ return MoveToBottomSheetFolderEntryModel(matcher, index = 0) // Always 0, no duplicates in names.
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/conversation/MessageBannerEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/conversation/MessageBannerEntryModel.kt
new file mode 100644
index 0000000000..892dac2695
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/conversation/MessageBannerEntryModel.kt
@@ -0,0 +1,54 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.model.conversation
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.maildetail.presentation.ui.MessageBodyTestTags
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.child
+
+internal class MessageBannerEntryModel(composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule) {
+
+ private val rootItem = composeTestRule.onNodeWithTag(MessageBodyTestTags.MessageBodyBanner)
+
+ private val icon = rootItem.child {
+ hasTestTag(MessageBodyTestTags.MessageBodyBannerIcon)
+ }
+
+ private val text = rootItem.child {
+ hasTestTag(MessageBodyTestTags.MessageBodyBannerText)
+ }
+
+ // region verification
+ fun doesNotExist() {
+ rootItem.assertDoesNotExist()
+ }
+
+ fun isDisplayedWithText(value: String) {
+ rootItem.awaitDisplayed()
+ icon.assertIsDisplayed()
+ text.assertTextEquals(value)
+ }
+ // endregion
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/conversation/MessageCollapsedItemEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/conversation/MessageCollapsedItemEntryModel.kt
new file mode 100644
index 0000000000..719a8f42c4
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/conversation/MessageCollapsedItemEntryModel.kt
@@ -0,0 +1,166 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.model.conversation
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailcommon.presentation.compose.AvatarTestTags
+import ch.protonmail.android.mailcommon.presentation.compose.OfficialBadgeTestTags
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailCollapsedMessageHeaderTestTags
+import ch.protonmail.android.test.R
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.util.child
+import ch.protonmail.android.uitest.util.getTestString
+
+@Suppress("TooManyFunctions")
+internal data class MessageCollapsedItemEntryModel(
+ private val index: Int,
+ private val composeTestRule: ComposeTestRule
+) {
+
+ private val rootItem = composeTestRule.onAllNodesWithTag(
+ testTag = ConversationDetailCollapsedMessageHeaderTestTags.RootItem,
+ useUnmergedTree = true
+ )[index]
+
+ private val avatarRootItem = rootItem.child {
+ hasTestTag(AvatarTestTags.AvatarRootItem)
+ }
+
+ private val avatar = avatarRootItem.child {
+ hasTestTag(AvatarTestTags.AvatarText)
+ }
+
+ private val avatarDraft = avatarRootItem.child {
+ hasTestTag(AvatarTestTags.AvatarDraft)
+ }
+
+ private val attachmentIcon = rootItem.child {
+ hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.AttachmentIcon)
+ }
+
+ private val repliedIcon = rootItem.child {
+ hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.RepliedIcon)
+ }
+
+ private val repliedAllIcon = rootItem.child {
+ hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.RepliedAllIcon)
+ }
+
+ private val forwardedIcon = rootItem.child {
+ hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.ForwardedIcon)
+ }
+
+ private val starIcon = rootItem.child {
+ hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.StarIcon)
+ }
+
+ private val sender = rootItem.child {
+ hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.Sender)
+ }
+
+ private val authenticityBadge = rootItem.child {
+ hasTestTag(OfficialBadgeTestTags.Item)
+ }
+
+ private val expirationIcon = rootItem.child {
+ hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.ExpirationIcon)
+ }
+
+ private val expirationText = rootItem.child {
+ hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.ExpirationText)
+ }
+
+ private val locationIcon = rootItem.child {
+ hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.Location)
+ }
+
+ private val time = rootItem.child {
+ hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.Time)
+ }
+
+ // region actions
+ fun click(): MessageCollapsedItemEntryModel = apply {
+ rootItem.performClick()
+ }
+ // endregion
+
+ // region verification
+ fun isNotDisplayed(): MessageCollapsedItemEntryModel = apply {
+ rootItem.assertDoesNotExist()
+ }
+
+ fun hasAvatar(initial: AvatarInitial): MessageCollapsedItemEntryModel = apply {
+ when (initial) {
+ is AvatarInitial.WithText -> avatar.assertTextEquals(initial.text)
+ is AvatarInitial.Draft -> avatarDraft.assertIsDisplayed()
+ }
+ }
+
+ fun hasAttachmentIcon(): MessageCollapsedItemEntryModel = apply {
+ attachmentIcon.assertIsDisplayed()
+ }
+
+ fun hasRepliedIcon(): MessageCollapsedItemEntryModel = apply {
+ repliedIcon.assertIsDisplayed()
+ }
+
+ fun hasRepliedAllIcon(): MessageCollapsedItemEntryModel = apply {
+ repliedAllIcon.assertIsDisplayed()
+ }
+
+ fun hasForwardedIcon(): MessageCollapsedItemEntryModel = apply {
+ forwardedIcon.assertIsDisplayed()
+ }
+
+ fun hasStarIcon(): MessageCollapsedItemEntryModel = apply {
+ starIcon.assertIsDisplayed()
+ }
+
+ fun hasSender(value: String): MessageCollapsedItemEntryModel = apply {
+ sender.assertTextEquals(value)
+ }
+
+ fun hasAuthenticityBadge(expectedValue: Boolean): MessageCollapsedItemEntryModel = apply {
+ if (expectedValue) {
+ authenticityBadge.assertIsDisplayed()
+ authenticityBadge.assertTextEquals(getTestString(R.string.test_auth_badge_official))
+ } else {
+ authenticityBadge.assertDoesNotExist()
+ }
+ }
+
+ fun hasExpiration(value: String): MessageCollapsedItemEntryModel = apply {
+ expirationIcon.assertIsDisplayed()
+ expirationText.assertTextEquals(value)
+ }
+
+ fun hasLocationIcon(): MessageCollapsedItemEntryModel = apply {
+ locationIcon.assertIsDisplayed()
+ }
+
+ fun hasTime(value: String): MessageCollapsedItemEntryModel = apply {
+ time.assertTextEquals(value)
+ }
+ // endregion
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/DetailBottomBarSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/DetailBottomBarSection.kt
new file mode 100644
index 0000000000..72109eb55c
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/DetailBottomBarSection.kt
@@ -0,0 +1,74 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.section
+
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.maildetail.presentation.R
+import ch.protonmail.android.mailmessage.presentation.ui.bottomsheet.LabelAsBottomSheetTestTags
+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.detail.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.awaitHidden
+import ch.protonmail.android.uitest.util.onNodeWithContentDescription
+
+@AttachTo(
+ targets = [
+ ConversationDetailRobot::class,
+ MessageDetailRobot::class
+ ],
+ identifier = "bottomBarSection"
+)
+internal class DetailBottomBarSection : ComposeSectionRobot() {
+
+ fun markAsUnread() = apply {
+ composeTestRule.onNodeWithContentDescription(R.string.action_mark_unread_content_description).performClick()
+ }
+
+ fun moveToTrash() = apply {
+ composeTestRule.onNodeWithContentDescription(R.string.action_trash_content_description).performClick()
+ }
+
+ fun openMoveToBottomSheet() = apply {
+ composeTestRule.onNodeWithContentDescription(R.string.action_move_content_description).performClick()
+ }
+
+ fun openLabelAsBottomSheet() = apply {
+ composeTestRule.onNodeWithContentDescription(R.string.action_label_content_description).performClick()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+ fun labelAsBottomSheetExists() {
+ composeTestRule.onNodeWithTag(LabelAsBottomSheetTestTags.RootItem, useUnmergedTree = true)
+ .awaitDisplayed()
+ .assertExists()
+ }
+
+ fun labelAsBottomSheetIsDismissed() {
+ composeTestRule.onNodeWithTag(LabelAsBottomSheetTestTags.RootItem, useUnmergedTree = true)
+ .awaitHidden()
+ .assertIsNotDisplayed()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/DetailTopBarSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/DetailTopBarSection.kt
new file mode 100644
index 0000000000..4bd2aab10e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/DetailTopBarSection.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.section
+
+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.detail.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.model.DetailScreenTopBarEntryModel
+
+@AttachTo(targets = [ConversationDetailRobot::class, MessageDetailRobot::class])
+internal class DetailTopBarSection : ComposeSectionRobot() {
+
+ private val topBarDetailModel = DetailScreenTopBarEntryModel(composeTestRule)
+
+ fun tapBackButton() {
+ topBarDetailModel.tapBack()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun hasSubject(subject: String) = apply {
+ topBarDetailModel.hasSubject(subject)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageActionsSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageActionsSection.kt
new file mode 100644
index 0000000000..5ec9cb3cc6
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageActionsSection.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.section
+
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import ch.protonmail.android.maildetail.presentation.ui.MessageBodyTestTags
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot
+import ch.protonmail.android.uitest.util.child
+
+@AttachTo(targets = [ConversationDetailRobot::class, MessageDetailRobot::class], identifier = "actionsSection")
+internal class MessageActionsSection : ComposeSectionRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(MessageBodyTestTags.MessageActionsRootItem)
+
+ private val replyButton = rootItem.child {
+ hasTestTag(MessageBodyTestTags.MessageReplyButton)
+ }
+
+ private val replyAllButton = rootItem.child {
+ hasTestTag(MessageBodyTestTags.MessageReplyAllButton)
+ }
+
+ private val forwardButton = rootItem.child {
+ hasTestTag(MessageBodyTestTags.MessageForwardButton)
+ }
+
+ fun tapReplyButton() {
+ replyButton.performScrollTo().performClick()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageBannerSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageBannerSection.kt
new file mode 100644
index 0000000000..8646502a97
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageBannerSection.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.section
+
+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.detail.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.model.conversation.MessageBannerEntryModel
+import ch.protonmail.android.uitest.util.getTestString
+import ch.protonmail.android.test.R as testR
+
+@AttachTo(targets = [ConversationDetailRobot::class, MessageDetailRobot::class], identifier = "bannerSection")
+internal class MessageBannerSection : ComposeSectionRobot() {
+
+ private val messageBannerEntryModel = MessageBannerEntryModel()
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun hasBlockedEmbeddedImagesBannerDisplayed() {
+ messageBannerEntryModel.isDisplayedWithText(
+ getTestString(testR.string.test_message_body_embedded_images_banner_text)
+ )
+ }
+
+ fun hasBlockedRemoteImagesBannerDisplayed() {
+ messageBannerEntryModel.isDisplayedWithText(
+ getTestString(testR.string.test_message_body_remote_content_banner_text)
+ )
+ }
+
+ fun hasBlockerEmbeddedAndRemoteImagesBannerDisplayed() {
+ messageBannerEntryModel.isDisplayedWithText(
+ getTestString(testR.string.test_message_body_embedded_and_remote_content_banner_text)
+ )
+ }
+
+ fun hasBlockedContentBannerNotDisplayed() {
+ messageBannerEntryModel.doesNotExist()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageBodySection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageBodySection.kt
new file mode 100644
index 0000000000..0f42b4cd26
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageBodySection.kt
@@ -0,0 +1,168 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.section
+
+import androidx.annotation.StringRes
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.espresso.matcher.ViewMatchers.withClassName
+import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
+import androidx.test.espresso.web.model.Atoms.castOrDie
+import androidx.test.espresso.web.model.Atoms.script
+import androidx.test.espresso.web.sugar.Web
+import androidx.test.espresso.web.sugar.Web.onWebView
+import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
+import androidx.test.espresso.web.webdriver.DriverAtoms.getText
+import androidx.test.espresso.web.webdriver.Locator
+import ch.protonmail.android.maildetail.presentation.R
+import ch.protonmail.android.maildetail.presentation.ui.MessageBodyTestTags
+import ch.protonmail.android.mailmessage.presentation.ui.MessageBodyWebViewTestTags
+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.detail.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.onNodeWithText
+import org.hamcrest.CoreMatchers.containsString
+import org.hamcrest.CoreMatchers.equalTo
+import org.hamcrest.core.Is.`is`
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+@AttachTo(targets = [ConversationDetailRobot::class, MessageDetailRobot::class])
+internal class MessageBodySection : ComposeSectionRobot() {
+
+ private val webView: Web.WebInteraction<*> by lazy {
+ onWebView(withClassName(equalTo(WebViewClassName))).apply {
+ forceJavascriptEnabled()
+ }
+ }
+
+ private val webViewWrapper: SemanticsNodeInteraction by lazy {
+ composeTestRule.onNodeWithTag(MessageBodyWebViewTestTags.WebView)
+ }
+
+ private val webViewAlternative: SemanticsNodeInteraction by lazy {
+ composeTestRule.onNodeWithTag(MessageBodyTestTags.WebViewAlternative)
+ }
+
+ fun waitUntilMessageIsShown(timeout: Duration = 30.seconds) {
+ composeTestRule.waitForIdle()
+
+ // Wait for the WebView to appear.
+ webViewWrapper.awaitDisplayed(timeout = timeout)
+ }
+
+ @VerifiesOuter
+ internal inner class Verify {
+
+ fun isShowingMissingWebViewWarning() {
+ webViewAlternative.awaitDisplayed()
+ webViewWrapper.assertDoesNotExist()
+ }
+
+ fun messageInWebViewContains(messageBody: String, tagName: String = "html") {
+ webView.withElement(findElement(Locator.TAG_NAME, tagName))
+ .check(webMatches(getText(), containsString(messageBody)))
+ webViewAlternative.assertDoesNotExist()
+ }
+
+ fun loadingErrorMessageIsDisplayed(@StringRes errorMessage: Int) {
+ composeTestRule.onNodeWithText(errorMessage)
+ .assertIsDisplayed()
+ }
+
+ fun loadingErrorMessageIsDisplayed(errorMessage: String) {
+ composeTestRule.onNodeWithText(errorMessage)
+ .assertIsDisplayed()
+ }
+
+ fun bodyReloadButtonIsDisplayed() {
+ composeTestRule.onNodeWithText(R.string.reload)
+ .assertIsDisplayed()
+ }
+
+ fun bodyDecryptionErrorMessageIsDisplayed() {
+ composeTestRule.onNodeWithText(R.string.decryption_error)
+ .assertIsDisplayed()
+ }
+
+ fun hasRemoteImageLoaded(expected: Boolean) {
+ val jsSnippet = """
+ |var image = document.querySelector('img');
+ |var isLoaded = image.complete && image.naturalWidth != 0 && image.naturalHeight != 0;
+ |return isLoaded;
+ """.trimMargin()
+
+ runAndMatchJsCodeOutput(jsSnippet, expected)
+ }
+
+ fun hasHtmlContentSanitised() {
+ val jsSnippet = """
+ |var onLoad = document.querySelector('body').onload;
+ |var form = document.querySelector('form');
+ |var relLinks = document.querySelector('link');
+ |var iframe = document.querySelector('iframe');
+ |var ping = document.querySelector('a').ping;
+ |return onLoad == null && form == null && relLinks == null && iframe == null && ping == "";
+ """.trimMargin()
+
+ runAndMatchJsCodeOutput(jsSnippet, true)
+ }
+
+ fun hasEmbeddedImages(expectedNumber: Int) {
+ val jsSnippet = """
+ |var embeddedImages = Array.from(document.getElementsByClassName('proton-embedded'));
+ |return embeddedImages.length == $expectedNumber;
+ """.trimMargin()
+
+ runAndMatchJsCodeOutput(jsSnippet, true)
+ }
+
+ fun hasEmbeddedImagesSuccessfullyLoaded(expected: Boolean) {
+ val jsSnippet = """
+ |var embeddedImages = Array.from(document.getElementsByClassName('proton-embedded'));
+ |if (embeddedImages.length == 0) {
+ | return false;
+ |}
+ |var imagesLoaded = embeddedImages.every(value => value.complete && value.naturalHeight != 0 && value.naturalWidth != 0);
+ |return imagesLoaded;
+ """.trimMargin()
+
+ runAndMatchJsCodeOutput(jsSnippet, expected)
+ }
+
+ private fun runAndMatchJsCodeOutput(script: String, expected: Boolean) {
+ webView.check(
+ webMatches(
+ script(script, castOrDie(Boolean::class.javaObjectType)),
+ `is`(equalTo(expected))
+ )
+ )
+ }
+ }
+
+ private companion object {
+
+ const val WebViewClassName = "android.webkit.WebView"
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageExpandedHeaderSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageExpandedHeaderSection.kt
new file mode 100644
index 0000000000..215ec91221
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageExpandedHeaderSection.kt
@@ -0,0 +1,58 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.section
+
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.models.detail.ExtendedHeaderRecipientEntry
+import ch.protonmail.android.uitest.models.detail.MessageHeaderExpandedEntryModel
+import ch.protonmail.android.uitest.models.labels.LabelEntry
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+
+internal class MessageExpandedHeaderSection : ComposeSectionRobot() {
+
+ private val expandedHeader = MessageHeaderExpandedEntryModel(composeTestRule)
+
+ fun collapse() {
+ expandedHeader.collapse()
+ }
+
+ @VerifiesOuter
+ internal inner class Verify {
+
+ fun hasRecipients(vararg recipients: ExtendedHeaderRecipientEntry) {
+ expandedHeader.hasRecipients(*recipients)
+ }
+
+ fun hasLabels(vararg labels: LabelEntry) {
+ expandedHeader.hasLabels(*labels)
+ }
+
+ fun hasTime(value: String) {
+ expandedHeader.hasTime(value)
+ }
+
+ fun hasLocation(value: String) {
+ expandedHeader.hasLocation(value)
+ }
+
+ fun hasSize(value: String) {
+ expandedHeader.hasSize(value)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageFooterAttachmentSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageFooterAttachmentSection.kt
new file mode 100644
index 0000000000..45ceaa1d2b
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageFooterAttachmentSection.kt
@@ -0,0 +1,118 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.section
+
+import androidx.compose.ui.test.assertIsDisplayed
+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
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+import ch.protonmail.android.uitest.robot.composer.ComposerRobot
+import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailItemEntry
+import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailItemEntryModel
+import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailSummaryEntry
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.child
+
+@AttachTo(
+ targets = [
+ ComposerRobot::class,
+ ConversationDetailRobot::class,
+ MessageDetailRobot::class
+ ],
+ identifier = "attachmentsSection"
+)
+internal class MessageFooterAttachmentSection : ComposeSectionRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(
+ testTag = AttachmentFooterTestTags.Root,
+ useUnmergedTree = true
+ )
+
+ private val paperClipIcon = rootItem.child {
+ hasTestTag(AttachmentFooterTestTags.PaperClipIcon)
+ }
+
+ private val summaryText = rootItem.child {
+ hasTestTag(AttachmentFooterTestTags.SummaryText)
+ }
+
+ private val summarySize = rootItem.child {
+ hasTestTag(AttachmentFooterTestTags.SummarySize)
+ }
+
+ init {
+ scrollTo()
+ // If the section is expanding in a conversation detail, tapping via interactors might fail.
+ composeTestRule.waitForIdle()
+ }
+
+ fun tapItem(position: Int = 0) = withItemEntryModel(position) {
+ tapItem()
+ }
+
+ private fun scrollTo() {
+ Espresso.closeSoftKeyboard()
+ rootItem.awaitDisplayed().performScrollTo()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun hasLoaderDisplayedForItem(position: Int = 0) = withItemEntryModel(position) {
+ hasLoaderIcon()
+ }
+
+ fun hasLoaderNotDisplayedForItem(position: Int = 0) = withItemEntryModel(position) {
+ hasNoLoaderIcon()
+ }
+
+ fun hasSummaryDetails(details: AttachmentDetailSummaryEntry) {
+ paperClipIcon.assertIsDisplayed()
+ summaryText.assertTextEquals(details.summary)
+ summarySize.assertTextEquals(details.size)
+ }
+
+ fun hasAttachments(vararg entries: AttachmentDetailItemEntry) {
+ for (entry in entries) {
+ withItemEntryModel(entry.index) {
+ waitUntilShown()
+ .hasIcon()
+ .hasName(entry.fileName)
+ .hasSize(entry.fileSize)
+ .also {
+ if (entry.hasDeleteIcon) it.hasDeleteIcon() else it.hasNoDeleteIcon()
+ }
+ }
+ }
+ }
+ }
+
+ private fun withItemEntryModel(position: Int, block: AttachmentDetailItemEntryModel.() -> Unit) {
+ val model = AttachmentDetailItemEntryModel(position, rootItem)
+ block(model)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageHeaderSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageHeaderSection.kt
new file mode 100644
index 0000000000..6da4b31089
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageHeaderSection.kt
@@ -0,0 +1,95 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.section
+
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.detail.MessageHeaderEntryModel
+import ch.protonmail.android.uitest.models.labels.LabelEntry
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot
+
+@AttachTo(targets = [ConversationDetailRobot::class, MessageDetailRobot::class])
+internal class MessageHeaderSection : ComposeSectionRobot() {
+
+ private val headerModel = MessageHeaderEntryModel(composeTestRule)
+
+ fun collapseMessage() = apply {
+ headerModel.collapseMessage()
+ }
+
+ fun expandHeader() = apply {
+ headerModel.click()
+ }
+
+ fun tapReplyButton() = apply {
+ headerModel.tapReplyButton()
+ }
+
+ // The `expanded` subsection is an exception, but necessary to split the header checks reasonably.
+ fun expanded(block: MessageExpandedHeaderSection.() -> Unit) = MessageExpandedHeaderSection().apply(block)
+
+ @VerifiesOuter
+ inner class Verify {
+
+ private val headerModel = MessageHeaderEntryModel(composeTestRule)
+
+ fun headerIsDisplayed() {
+ headerModel.isDisplayed()
+ }
+
+ fun hasAvatarInitial(initial: AvatarInitial) {
+ headerModel.hasAvatar(initial)
+ }
+
+ fun hasSenderName(sender: String) {
+ headerModel.hasSenderName(sender)
+ }
+
+ fun hasSenderAddress(address: String) {
+ headerModel.hasSenderAddress(address)
+ }
+
+ fun hasAuthenticityBadge(value: Boolean) {
+ headerModel.hasAuthenticityBadge(value)
+ }
+
+ fun hasRecipient(value: String) {
+ headerModel.hasRecipient(value)
+ }
+
+ fun hasTime(value: String) = apply {
+ headerModel.hasDate(value)
+ }
+
+ fun hasLabels(vararg labels: LabelEntry) = apply {
+ headerModel.hasLabels(*labels)
+ }
+
+ fun hasReplyButton() = apply {
+ headerModel.hasReplyButton()
+ }
+
+ fun hasReplyAllButton() = apply {
+ headerModel.hasReplyAllButton()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MoveToBottomSheetSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MoveToBottomSheetSection.kt
new file mode 100644
index 0000000000..45982588d8
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MoveToBottomSheetSection.kt
@@ -0,0 +1,112 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.section
+
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.isNotDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailmessage.presentation.ui.bottomsheet.MoveToBottomSheetTestTags
+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.detail.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntry
+import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntryModel
+import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.awaitEnabled
+import ch.protonmail.android.uitest.util.awaitHidden
+import ch.protonmail.android.uitest.util.child
+import ch.protonmail.android.uitest.util.getTestString
+import ch.protonmail.android.test.R as testR
+
+@AttachTo(targets = [ConversationDetailRobot::class, MessageDetailRobot::class])
+internal class MoveToBottomSheetSection : ComposeSectionRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(
+ testTag = MoveToBottomSheetTestTags.RootItem,
+ useUnmergedTree = true
+ )
+
+ private val headerText = rootItem.child {
+ hasTestTag(MoveToBottomSheetTestTags.MoveToText)
+ }
+
+ private val doneButton = rootItem.child {
+ hasTestTag(MoveToBottomSheetTestTags.DoneButton)
+ }
+
+ fun tapDoneButton() {
+ doneButton.awaitEnabled().performClick()
+ }
+
+ fun selectFolderAtPosition(index: Int) {
+ val model = MoveToBottomSheetFolderEntryModel(index)
+
+ model.click()
+ }
+
+ fun selectFolderWithName(name: String) {
+ val model = MoveToBottomSheetFolderEntryModel(folderName = name)
+
+ model.click()
+ }
+
+ fun dismiss() {
+ uiDevice.pressBack()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isShown() {
+ rootItem
+ .awaitDisplayed()
+ .assertExists()
+ }
+
+ fun isHidden() {
+ rootItem
+ .awaitHidden()
+ .isNotDisplayed()
+ }
+
+ fun headerTextIsShown() {
+ headerText.assertTextEquals(getTestString(testR.string.test_bottom_sheet_move_to_title))
+ }
+
+ fun doneButtonIsShown() {
+ doneButton.assertTextEquals(getTestString(testR.string.test_bottom_sheet_done_action))
+ }
+
+ fun hasFolders(vararg entries: MoveToBottomSheetFolderEntry) {
+ entries.forEach {
+ val entryModel = MoveToBottomSheetFolderEntryModel(it.index)
+
+ entryModel
+ .hasIcon(it.iconTint)
+ .hasText(it.name)
+ .also { model -> if (it.isSelected) model.hasSelectionIcon() else model.hasNoSelectionIcon() }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/conversation/ConversationDetailCollapsedMessagesSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/conversation/ConversationDetailCollapsedMessagesSection.kt
new file mode 100644
index 0000000000..4d8e2a11c7
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/conversation/ConversationDetailCollapsedMessagesSection.kt
@@ -0,0 +1,101 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.detail.section.conversation
+
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeUp
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreenTestTags
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.model.conversation.MessageCollapsedItemEntryModel
+
+@AttachTo(targets = [ConversationDetailRobot::class], identifier = "messagesCollapsedSection")
+internal class ConversationDetailCollapsedMessagesSection : ComposeSectionRobot() {
+
+ private val messagesList = composeTestRule.onNodeWithTag(ConversationDetailScreenTestTags.MessagesList)
+
+ fun scrollToTop() {
+ messagesList.performTouchInput { swipeUp() }
+ }
+
+ fun openMessageAtIndex(index: Int) = withEntryModel(index) {
+ click()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun collapsedHeaderIsNotDisplayed() = withEntryModel(index = 0) {
+ isNotDisplayed()
+ }
+
+ fun avatarInitialIsDisplayed(index: Int, text: String) = withEntryModel(index) {
+ hasAvatar(AvatarInitial.WithText(text))
+ }
+
+ fun avatarDraftIsDisplayed(index: Int) = withEntryModel(index) {
+ hasAvatar(AvatarInitial.Draft)
+ }
+
+ fun attachmentIconIsDisplayed(index: Int) = withEntryModel(index) {
+ hasAttachmentIcon()
+ }
+
+ fun forwardedIconIsDisplayed(index: Int) = withEntryModel(index) {
+ hasForwardedIcon()
+ }
+
+ fun repliedAllIconIsDisplayed(index: Int) = withEntryModel(index) {
+ hasRepliedAllIcon()
+ }
+
+ fun repliedIconIsDisplayed(index: Int) = withEntryModel(index) {
+ hasRepliedIcon()
+ }
+
+ fun senderNameIsDisplayed(index: Int, value: String) = withEntryModel(index) {
+ hasSender(value)
+ }
+
+ fun authenticityBadgeIsDisplayed(index: Int, value: Boolean) = withEntryModel(index) {
+ hasAuthenticityBadge(value)
+ }
+
+ fun expirationIsDisplayed(index: Int, value: String) = withEntryModel(index) {
+ hasExpiration(value)
+ }
+
+ fun starIconIsDisplayed(index: Int) = withEntryModel(index) {
+ hasStarIcon()
+ }
+
+ fun timeIsDisplayed(index: Int, value: String) = withEntryModel(index) {
+ hasTime(value)
+ }
+ }
+
+ private fun withEntryModel(index: Int, block: MessageCollapsedItemEntryModel.() -> MessageCollapsedItemEntryModel) {
+ val entryModel = MessageCollapsedItemEntryModel(index, composeTestRule)
+ block(entryModel)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/DeviceRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/DeviceRobot.kt
new file mode 100644
index 0000000000..f020119b33
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/DeviceRobot.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.robot.helpers
+
+import androidx.lifecycle.Lifecycle
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso
+import ch.protonmail.android.MainActivity
+import ch.protonmail.android.test.ksp.annotations.AsDsl
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.test.robot.ProtonMailRobot
+import ch.protonmail.android.uitest.util.ActivityScenarioHolder
+import org.junit.Assert.assertEquals
+
+@AsDsl
+internal class DeviceRobot : ProtonMailRobot {
+
+ private val activityScenario: ActivityScenario
+ get() = ActivityScenarioHolder.scenario
+
+ fun pressBack() {
+ Espresso.pressBackUnconditionally()
+ }
+
+ fun triggerActivityRecreation() {
+ activityScenario.recreate()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isMainActivityNotDisplayed() {
+ assertEquals(Lifecycle.State.DESTROYED, activityScenario.state)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/MockRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/MockRobot.kt
new file mode 100644
index 0000000000..0df407148a
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/MockRobot.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.helpers
+
+import ch.protonmail.android.test.ksp.annotations.AsDsl
+import ch.protonmail.android.test.robot.ProtonMailRobot
+
+@AsDsl
+internal class MockRobot : ProtonMailRobot
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/models/NotificationEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/models/NotificationEntry.kt
new file mode 100644
index 0000000000..f7370ac6d2
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/models/NotificationEntry.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.helpers.models
+
+internal data class NotificationEntry(
+ val title: String,
+ val body: String? = null,
+ val isClearable: Boolean
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotExternalStorageSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotExternalStorageSection.kt
new file mode 100644
index 0000000000..9991cf3650
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotExternalStorageSection.kt
@@ -0,0 +1,59 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.helpers.section
+
+import java.io.File
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.test.robot.ProtonMailSectionRobot
+import ch.protonmail.android.uitest.robot.helpers.DeviceRobot
+import kotlin.test.assertNotNull
+
+@AttachTo(targets = [DeviceRobot::class], identifier = "storage")
+internal class DeviceRobotExternalStorageSection : ProtonMailSectionRobot {
+
+ private val baseDir = File(DefaultDownloadPath)
+
+ @VerifiesOuter
+ inner class Verify {
+
+ private val String.rawName: String
+ get() = this.split(".")[0]
+
+ private val String.extension: String
+ get() = this.split(".")[1]
+
+ fun containsFileInDownloadsWithName(fileName: String) {
+ // Multiple files with the same name have "(1)", "(2)"... appended before the extension.
+ // We want to match "File.jpg"/"File (1).jpg" and so on.
+ val regex = "${fileName.rawName}(\\s)?(\\([0-9]+\\))?\\.${fileName.extension}".toRegex()
+
+ val actualFile = baseDir.listFiles()?.any {
+ it.name.matches(regex)
+ }
+
+ assertNotNull(actualFile)
+ }
+ }
+
+ private companion object {
+
+ const val DefaultDownloadPath = "/sdcard/Download"
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotIntentsSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotIntentsSection.kt
new file mode 100644
index 0000000000..69bc3dbb78
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotIntentsSection.kt
@@ -0,0 +1,87 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.helpers.section
+
+import android.content.Intent
+import android.net.Uri
+import androidx.test.espresso.intent.Intents.intended
+import androidx.test.espresso.intent.Intents.times
+import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
+import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
+import androidx.test.espresso.intent.matcher.IntentMatchers.hasType
+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.helpers.DeviceRobot
+import org.hamcrest.CoreMatchers.allOf
+import org.hamcrest.CoreMatchers.any
+import org.hamcrest.Matcher
+
+@AttachTo(targets = [DeviceRobot::class], identifier = "intents")
+internal class DeviceRobotIntentsSection : ComposeSectionRobot() {
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun filePickerIntentWasLaunched(
+ times: Int = 1,
+ mimeType: String = "*/*",
+ timeout: Long = 5000L
+ ) {
+ composeTestRule.waitUntil(timeout) {
+ runCatching {
+ intended(allOf(hasAction(Intent.ACTION_GET_CONTENT), mimeType.asMimeTypeMatcher()), times(times))
+ }.isSuccess
+ }
+ }
+
+ fun actionViewUriIntentWasLaunched(
+ times: Int = 1,
+ url: String,
+ timeout: Long = 10_000L
+ ) {
+ composeTestRule.waitUntil(timeout) {
+ runCatching {
+ intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(Uri.parse(url))), times(times))
+ }.isSuccess
+ }
+ }
+
+ fun actionViewIntentWasLaunched(
+ times: Int = 1,
+ mimeType: String? = null,
+ timeout: Long = 10_000L
+ ) {
+ composeTestRule.waitUntil(timeout) {
+ runCatching {
+ intended(allOf(hasAction(Intent.ACTION_VIEW), mimeType.asMimeTypeMatcher()), times(times))
+ }.isSuccess
+ }
+ }
+
+ fun actionViewIntentWasNotLaunched(mimeType: String? = null) {
+ actionViewIntentWasLaunched(times = 0, mimeType = mimeType)
+ }
+
+ private fun String?.asMimeTypeMatcher(): Matcher {
+ this ?: return hasType(any(String::class.java))
+ return hasType(this)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotNotificationsSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotNotificationsSection.kt
new file mode 100644
index 0000000000..abca580faa
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotNotificationsSection.kt
@@ -0,0 +1,76 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.helpers.section
+
+import android.app.Notification
+import android.app.NotificationManager
+import android.content.Context
+import android.service.notification.StatusBarNotification
+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.helpers.DeviceRobot
+import ch.protonmail.android.uitest.robot.helpers.models.NotificationEntry
+import ch.protonmail.android.uitest.util.InstrumentationHolder
+import kotlin.test.assertNotNull
+
+@AttachTo(targets = [DeviceRobot::class], identifier = "notificationsSection")
+internal class DeviceRobotNotificationsSection : ComposeSectionRobot() {
+
+ private val notificationManager: NotificationManager
+ get() = InstrumentationHolder.instrumentation.targetContext
+ .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ @VerifiesOuter
+ inner class Verify {
+
+ private val StatusBarNotification.title: String?
+ get() = notification.extras.getString(Notification.EXTRA_TITLE)
+
+ private val StatusBarNotification.body: String?
+ get() = notification.extras.getString(Notification.EXTRA_TEXT)
+
+ fun hasNotificationDisplayed(entry: NotificationEntry) {
+ composeTestRule.waitForIdle()
+
+ composeTestRule.waitUntil(NotificationTimeout) {
+ notificationManager.activeNotifications.isNotEmpty()
+ }
+
+ val notification = notificationManager.activeNotifications.find {
+ entry.title == it.title && entry.body == it.body && entry.isClearable == it.isClearable
+ }
+
+ assertNotNull(notification)
+ }
+
+ fun hasNoNotificationsDisplayed() {
+ composeTestRule.waitForIdle()
+
+ composeTestRule.waitUntil(NotificationTimeout) {
+ notificationManager.activeNotifications.isEmpty()
+ }
+ }
+ }
+
+ private companion object {
+
+ const val NotificationTimeout = 5000L
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotSoftKeysSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotSoftKeysSection.kt
new file mode 100644
index 0000000000..3fa5e4f477
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotSoftKeysSection.kt
@@ -0,0 +1,34 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.helpers.section
+
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.test.robot.ProtonMailSectionRobot
+import ch.protonmail.android.uitest.robot.helpers.DeviceRobot
+import ch.protonmail.android.uitest.util.UiDeviceHolder
+
+@AttachTo(targets = [DeviceRobot::class], identifier = "deviceSoftKeys")
+internal class DeviceRobotSoftKeysSection : ProtonMailSectionRobot {
+
+ private val uiDevice = UiDeviceHolder.uiDevice
+
+ fun pressHomeButton() {
+ uiDevice.pressHome()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/MockRobotTimeSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/MockRobotTimeSection.kt
new file mode 100644
index 0000000000..1d7fbae624
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/MockRobotTimeSection.kt
@@ -0,0 +1,37 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.helpers.section
+
+import java.time.Instant
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.test.robot.ProtonMailSectionRobot
+import ch.protonmail.android.uitest.robot.helpers.MockRobot
+import io.mockk.every
+import io.mockk.mockk
+
+@AttachTo(targets = [MockRobot::class], identifier = "time")
+internal class MockRobotTimeSection : ProtonMailSectionRobot {
+
+ fun forceCurrentMillisTo(millis: Long) {
+ every { Instant.now() } returns mockk {
+ every { nano } returns 0
+ every { epochSecond } returns millis
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/MailboxRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/MailboxRobot.kt
new file mode 100644
index 0000000000..b051c6bcb9
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/MailboxRobot.kt
@@ -0,0 +1,43 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.mailbox
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags
+import ch.protonmail.android.test.ksp.annotations.AsDsl
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+
+@AsDsl
+internal class MailboxRobot : ComposeRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(MailboxScreenTestTags.Root)
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isShown() {
+ rootItem
+ .awaitDisplayed()
+ .assertIsDisplayed()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/model/snackbar/MailboxSnackbar.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/model/snackbar/MailboxSnackbar.kt
new file mode 100644
index 0000000000..d0e398e576
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/model/snackbar/MailboxSnackbar.kt
@@ -0,0 +1,31 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.mailbox.model.snackbar
+
+import ch.protonmail.android.test.R
+import ch.protonmail.android.uitest.models.snackbar.SnackbarEntry
+import ch.protonmail.android.uitest.models.snackbar.SnackbarType
+import ch.protonmail.android.uitest.util.getTestString
+
+internal sealed class MailboxSnackbar(value: String, type: SnackbarType) : SnackbarEntry(value, type) {
+
+ object FailedToLoadNewItems : MailboxSnackbar(
+ getTestString(R.string.test_mailbox_error_message_generic), SnackbarType.Error
+ )
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxAppendErrorSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxAppendErrorSection.kt
new file mode 100644
index 0000000000..01c75a85ba
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxAppendErrorSection.kt
@@ -0,0 +1,68 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.mailbox.section
+
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags
+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.mailbox.MailboxRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.awaitHidden
+import ch.protonmail.android.uitest.util.child
+import ch.protonmail.android.uitest.util.getTestString
+import ch.protonmail.android.test.R as testR
+
+@AttachTo(targets = [MailboxRobot::class], identifier = "appendErrorSection")
+internal class MailboxAppendErrorSection : ComposeSectionRobot() {
+
+ private val appendErrorRootItem = composeTestRule.onNodeWithTag(MailboxScreenTestTags.MailboxAppendError)
+
+ private val errorDescription = appendErrorRootItem.child {
+ hasTestTag(MailboxScreenTestTags.MailboxAppendErrorText)
+ }
+
+ private val retryButton = appendErrorRootItem.child {
+ hasTestTag(MailboxScreenTestTags.MailboxAppendErrorButton)
+ }
+
+ fun tapRetryButton() {
+ retryButton.performClick()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isHidden() {
+ appendErrorRootItem
+ .awaitHidden()
+ .assertDoesNotExist()
+ }
+
+ fun isShown() {
+ appendErrorRootItem.awaitDisplayed()
+ errorDescription.assertTextEquals(getTestString(testR.string.test_mailbox_error_message_generic))
+ retryButton.assertTextEquals(getTestString(testR.string.test_retry))
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxAppendLoadingSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxAppendLoadingSection.kt
new file mode 100644
index 0000000000..c31cc72209
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxAppendLoadingSection.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.robot.mailbox.section
+
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags
+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.mailbox.MailboxRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.awaitHidden
+
+@AttachTo(targets = [MailboxRobot::class], identifier = "appendLoadingSection")
+internal class MailboxAppendLoadingSection : ComposeSectionRobot() {
+
+ private val loadingItem = composeTestRule.onNodeWithTag(MailboxScreenTestTags.MailboxAppendLoader)
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isShown() {
+ loadingItem.awaitDisplayed()
+ }
+
+ fun isHidden() {
+ loadingItem.awaitHidden()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxEmptyListSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxEmptyListSection.kt
new file mode 100644
index 0000000000..dd32d6665b
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxEmptyListSection.kt
@@ -0,0 +1,54 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.mailbox.section
+
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeDown
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags
+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.mailbox.MailboxRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.child
+
+@AttachTo(targets = [MailboxRobot::class], identifier = "emptyListSection")
+internal class MailboxEmptyListSection : ComposeSectionRobot(), RefreshableSection {
+
+ private val emptyList = composeTestRule.onNodeWithTag(MailboxScreenTestTags.MailboxEmptyRoot)
+ private val image = emptyList.child { hasTestTag(MailboxScreenTestTags.MailboxEmptyImage) }
+ private val title = emptyList.child { hasTestTag(MailboxScreenTestTags.MailboxEmptyTitle) }
+ private val subtitle = emptyList.child { hasTestTag(MailboxScreenTestTags.MailboxEmptySubtitle) }
+
+ override fun pullDownToRefresh() {
+ emptyList.performTouchInput { swipeDown() }
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isShown() {
+ image.awaitDisplayed()
+ title.awaitDisplayed()
+ subtitle.awaitDisplayed()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxErrorSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxErrorSection.kt
new file mode 100644
index 0000000000..95d3ab4bf4
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxErrorSection.kt
@@ -0,0 +1,55 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.mailbox.section
+
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeDown
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags
+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.mailbox.MailboxRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.child
+import ch.protonmail.android.uitest.util.getTestString
+import ch.protonmail.android.test.R as testR
+
+@AttachTo(targets = [MailboxRobot::class], identifier = "fullScreenErrorSection")
+internal class MailboxErrorSection : ComposeSectionRobot(), RefreshableSection {
+
+ private val rootItem = composeTestRule.onNodeWithTag(MailboxScreenTestTags.MailboxError)
+ private val errorLabel = rootItem.child { hasTestTag(MailboxScreenTestTags.MailboxErrorMessage) }
+
+ override fun pullDownToRefresh() {
+ rootItem.performTouchInput { swipeDown() }
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isShown() {
+ errorLabel
+ .awaitDisplayed()
+ .assertTextEquals(getTestString(testR.string.test_mailbox_error_message_generic))
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxListSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxListSection.kt
new file mode 100644
index 0000000000..faaf050a61
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxListSection.kt
@@ -0,0 +1,109 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.mailbox.section
+
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performScrollToIndex
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeDown
+import androidx.test.espresso.action.ViewActions.swipeUp
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntryModel
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+
+@AttachTo(targets = [MailboxRobot::class], identifier = "listSection")
+internal class MailboxListSection : ComposeSectionRobot(), RefreshableSection {
+
+ private val messagesList = composeTestRule.onNodeWithTag(MailboxScreenTestTags.List)
+
+ override fun pullDownToRefresh() {
+ messagesList.performTouchInput { swipeDown() }
+ }
+
+ fun longPressItemAtPosition(position: Int) = onListItemEntryModel(position) {
+ longClick()
+ }
+
+ fun selectItemsAt(vararg positions: Int) = positions.forEach {
+ onListItemEntryModel(it) { selectEntry() }
+ }
+
+ fun unselectItemsAtPosition(vararg positions: Int) = positions.forEach {
+ onListItemEntryModel(it) { unselectEntry() }
+ }
+
+ fun clickMessageByPosition(position: Int) = onListItemEntryModel(position) {
+ click()
+ }
+
+ fun scrollToItemAtIndex(index: Int) {
+ messagesList
+ .awaitDisplayed()
+ .performScrollToIndex(index)
+ }
+
+ fun scrollToBottom() = apply {
+ messagesList
+ .awaitDisplayed()
+ .performTouchInput { swipeUp() }
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun listItemsAreShown(vararg mailboxItemEntries: MailboxListItemEntry) {
+ for (entry in mailboxItemEntries) {
+ onListItemEntryModel(entry.index) {
+ hasAvatar(entry.avatarInitial)
+ .hasParticipants(entry.participants)
+ .hasSubject(entry.subject)
+ .hasDate(entry.date)
+
+ entry.locationIcons?.let { hasLocationIcons(it) } ?: hasNoLocationIcons()
+ entry.labels?.let { hasLabels(it) } ?: hasNoLabels()
+ entry.count?.let { hasCount(it) } ?: hasNoCount()
+ }
+ }
+ }
+
+ fun selectedItemAtPosition(position: Int) {
+ onListItemEntryModel(position) { isSelected() }
+ }
+
+ fun unSelectedItemAtPosition(position: Int) {
+ onListItemEntryModel(position) { isNotSelected() }
+ }
+
+ fun unreadItemAtPosition(position: Int) = onListItemEntryModel(position) {
+ assertUnread()
+ }
+
+ fun readItemAtPosition(position: Int) = onListItemEntryModel(position) {
+ assertRead()
+ }
+ }
+
+ private fun onListItemEntryModel(position: Int, block: MailboxListItemEntryModel.() -> Unit) =
+ block(MailboxListItemEntryModel(position))
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxProgressListSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxProgressListSection.kt
new file mode 100644
index 0000000000..875cd1a5be
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxProgressListSection.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.robot.mailbox.section
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags
+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.mailbox.MailboxRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+
+@AttachTo(targets = [MailboxRobot::class], identifier = "progressListSection")
+internal class MailboxProgressListSection : ComposeSectionRobot() {
+
+ private val progressList = composeTestRule.onNodeWithTag(MailboxScreenTestTags.ListProgress)
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isShown() {
+ progressList
+ .awaitDisplayed()
+ .assertIsDisplayed()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxStickyHeaderSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxStickyHeaderSection.kt
new file mode 100644
index 0000000000..f6d812b19c
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxStickyHeaderSection.kt
@@ -0,0 +1,59 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.mailbox.section
+
+import androidx.compose.ui.test.assertIsNotSelected
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailmailbox.presentation.mailbox.UnreadItemsFilterTestTags
+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.mailbox.MailboxRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+
+@AttachTo(targets = [MailboxRobot::class], identifier = "stickyHeaderSection")
+internal class MailboxStickyHeaderSection : ComposeSectionRobot() {
+
+ private val unreadFilterChip = composeTestRule
+ .onNodeWithTag(UnreadItemsFilterTestTags.UnreadFilterChip)
+
+ fun filterUnreadMessages() {
+ unreadFilterChip
+ .awaitDisplayed()
+ .performClick()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun unreadFilterIsDisplayed() {
+ unreadFilterChip
+ .awaitDisplayed()
+ .assertIsNotSelected()
+ }
+
+ fun unreadFilterIsSelected() {
+ unreadFilterChip
+ .awaitDisplayed()
+ .assertIsSelected()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxTopBarSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxTopBarSection.kt
new file mode 100644
index 0000000000..d40b31b401
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxTopBarSection.kt
@@ -0,0 +1,106 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.mailbox.section
+
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxTopAppBarTestTags
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxTopAppBarTestTags.NavigationButton
+import ch.protonmail.android.test.ksp.annotations.AttachTo
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.models.mailbox.MailboxType
+import ch.protonmail.android.uitest.robot.ComposeSectionRobot
+import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.child
+import ch.protonmail.android.uitest.util.getTestString
+import ch.protonmail.android.test.R as testR
+
+@AttachTo(targets = [MailboxRobot::class], identifier = "topAppBarSection")
+internal class MailboxTopBarSection : ComposeSectionRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(MailboxTopAppBarTestTags.RootItem, useUnmergedTree = true)
+
+ private val navigationButton = rootItem.child {
+ hasTestTag(NavigationButton)
+ }
+
+ private val hamburgerMenuButton = navigationButton.child {
+ hasContentDescription(
+ getTestString(testR.string.test_mailbox_toolbar_menu_button_content_description)
+ )
+ }
+
+ private val exitSelectionButton = navigationButton.child {
+ hasContentDescription(
+ getTestString(testR.string.test_mailbox_toolbar_exit_selection_mode_button_content_description)
+ )
+ }
+
+ private val locationLabel = rootItem.child {
+ hasTestTag(MailboxTopAppBarTestTags.LocationLabel)
+ }
+
+ private val composerButton = rootItem.child {
+ hasTestTag(MailboxTopAppBarTestTags.ComposerButton)
+ }
+
+ fun tapComposerIcon() {
+ composerButton.awaitDisplayed().performClick()
+ }
+
+ fun tapExitSelectionMode() {
+ exitSelectionButton.performClick()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ /**
+ * Verifies that the current Mailbox is of the given [MailboxType].
+ *
+ * A [timeout] is provided as switching Mailbox does not trigger any loaders or idling resources.
+ * By waiting for the condition to be fulfilled, we prevent the automation from performing the check
+ * before the Mailbox is effectively switched, avoiding unnecessary test flakiness.
+ *
+ * @param type the Mailbox type (Inbox, Drafts, Sent...)
+ * @param timeout the max timeout for the check to be successful
+ *
+ */
+ fun isMailbox(type: MailboxType, timeout: Long = 2_000) {
+ composeTestRule.waitUntil(timeoutMillis = timeout) {
+ runCatching { locationLabel.assertTextEquals(type.name) }.isSuccess
+ }
+
+ hamburgerMenuButton.awaitDisplayed()
+ }
+
+ fun isInSelectionMode(numSelected: Int, timeout: Long = 2_000) {
+ composeTestRule.waitUntil(timeoutMillis = timeout) {
+ runCatching { locationLabel.assertTextEquals("$numSelected Selected") }.isSuccess
+ }
+
+ hamburgerMenuButton.assertDoesNotExist()
+ exitSelectionButton.awaitDisplayed()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/RefreshableSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/RefreshableSection.kt
new file mode 100644
index 0000000000..e8a1796b8f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/RefreshableSection.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.robot.mailbox.section
+
+/**
+ * Interface for Robot sections that are expected to support the Pull to Refresh action.
+ */
+internal interface RefreshableSection {
+
+ fun pullDownToRefresh()
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/menu/MenuRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/menu/MenuRobot.kt
new file mode 100644
index 0000000000..83225ff2ab
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/menu/MenuRobot.kt
@@ -0,0 +1,124 @@
+/*
+ * 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 .
+ */
+package ch.protonmail.android.uitest.robot.menu
+
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.onChild
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performScrollToNode
+import ch.protonmail.android.maillabel.domain.model.SystemLabelId
+import ch.protonmail.android.maillabel.presentation.sidebar.SidebarSystemLabelTestTags.BaseTag
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxTopAppBarTestTags
+import ch.protonmail.android.mailsidebar.presentation.SidebarMenuTestTags
+import ch.protonmail.android.test.ksp.annotations.AsDsl
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.models.folders.SidebarCustomItemEntry
+import ch.protonmail.android.uitest.models.folders.SidebarItemCustomFolderEntryModel
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot
+import ch.protonmail.android.uitest.robot.settings.SettingsRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.awaitHidden
+import ch.protonmail.android.uitest.util.child
+import ch.protonmail.android.uitest.util.getTestString
+import me.proton.core.label.domain.entity.LabelId
+import ch.protonmail.android.test.R as testR
+
+@AsDsl
+internal class MenuRobot : ComposeRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(SidebarMenuTestTags.Root)
+ private val hamburgerMenuButton = composeTestRule.onNodeWithTag(MailboxTopAppBarTestTags.NavigationButton)
+
+ fun openSidebarMenu(): MenuRobot = apply {
+ hamburgerMenuButton.awaitDisplayed().performClick()
+ rootItem.awaitDisplayed()
+ }
+
+ fun openInbox() = openMailbox(SystemLabelId.Inbox)
+
+ fun openDrafts() = openMailbox(SystemLabelId.Drafts)
+
+ fun openArchive() = openMailbox(SystemLabelId.Archive)
+
+ fun openSent() = openMailbox(SystemLabelId.Sent)
+
+ fun openSpam() = openMailbox(SystemLabelId.Spam)
+
+ fun openTrash() = openMailbox(SystemLabelId.Trash)
+
+ fun openAllMail() = openMailbox(SystemLabelId.AllMail)
+
+ fun openSettings(): SettingsRobot {
+ tapSidebarMenuItemWithText(getTestString(testR.string.test_mail_settings_settings))
+ return SettingsRobot()
+ }
+
+ fun openFolderWithName(folderName: String) {
+ tapSidebarMenuItemWithText(folderName)
+ }
+
+ fun openReportBugs() {
+ tapSidebarMenuItemWithText(getTestString(testR.string.test_report_a_problem))
+ }
+
+ fun openSubscription() {
+ tapSidebarMenuItemWithText(getTestString(testR.string.test_subscription))
+ }
+
+ fun tapSignOut() {
+ tapSidebarMenuItemWithText(getTestString(testR.string.test_signout))
+ }
+
+ private fun tapSidebarMenuItemWithText(value: String) {
+ rootItem.onChild()
+ .apply { performScrollToNode(hasText(value)) }
+ .child { hasText(value) }
+ .performClick()
+
+ composeTestRule.waitForIdle()
+ }
+
+ private fun openMailbox(id: SystemLabelId): MailboxRobot {
+ composeTestRule
+ .onNodeWithTag(id.labelId.testTag)
+ .performScrollTo()
+ .performClick()
+
+ rootItem.awaitHidden()
+ return MailboxRobot()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun customFoldersAreDisplayed(vararg folders: SidebarCustomItemEntry) {
+ folders.forEach {
+ val item = SidebarItemCustomFolderEntryModel(it.index)
+
+ item.hasText(it.name)
+ .withIconTint(it.iconTint)
+ }
+ }
+ }
+}
+
+private val LabelId.testTag: String
+ get() = "$BaseTag#${this.id}"
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/OnboardingRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/OnboardingRobot.kt
new file mode 100644
index 0000000000..2cc95dba50
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/OnboardingRobot.kt
@@ -0,0 +1,40 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.onboarding
+
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailonboarding.presentation.OnboardingScreenTestTags
+import ch.protonmail.android.test.ksp.annotations.AsDsl
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+
+@AsDsl
+internal class OnboardingRobot : ComposeRobot() {
+
+ private val rootItem = composeTestRule.onNodeWithTag(OnboardingScreenTestTags.RootItem)
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isShown() {
+ rootItem.awaitDisplayed()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingBottomSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingBottomSection.kt
new file mode 100644
index 0000000000..d8b125b15e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingBottomSection.kt
@@ -0,0 +1,48 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.onboarding.section
+
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailonboarding.presentation.OnboardingScreenTestTags
+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.onboarding.OnboardingRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.child
+
+@AttachTo(targets = [OnboardingRobot::class], identifier = "bottomSection")
+internal class OnboardingBottomSection : ComposeSectionRobot() {
+
+ private val parent = composeTestRule.onNodeWithTag(OnboardingScreenTestTags.RootItem)
+ private val bottomButton = parent.child {
+ hasTestTag(OnboardingScreenTestTags.BottomButton)
+ }
+
+ // Interaction methods
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isBottomButtonShown() {
+ bottomButton.awaitDisplayed()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingMiddleSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingMiddleSection.kt
new file mode 100644
index 0000000000..3f04529d4f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingMiddleSection.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.robot.onboarding.section
+
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.isDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailonboarding.presentation.OnboardingScreenTestTags
+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.onboarding.OnboardingRobot
+import ch.protonmail.android.uitest.util.child
+
+@AttachTo(targets = [OnboardingRobot::class], identifier = "middleSection")
+internal class OnboardingMiddleSection : ComposeSectionRobot() {
+
+ private val parent = composeTestRule.onNodeWithTag(OnboardingScreenTestTags.RootItem)
+ private val onboardingImage = parent.child {
+ hasTestTag(OnboardingScreenTestTags.OnboardingImage)
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isOnboardingImageShown() {
+ onboardingImage.isDisplayed()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingTopBarSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingTopBarSection.kt
new file mode 100644
index 0000000000..0d076f36c5
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingTopBarSection.kt
@@ -0,0 +1,48 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.onboarding.section
+
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailonboarding.presentation.OnboardingScreenTestTags
+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.onboarding.OnboardingRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.child
+
+@AttachTo(targets = [OnboardingRobot::class], identifier = "topBarSection")
+internal class OnboardingTopBarSection : ComposeSectionRobot() {
+
+ private val parent = composeTestRule.onNodeWithTag(OnboardingScreenTestTags.TopBarRootItem)
+ private val closeButton = parent.child {
+ hasTestTag(OnboardingScreenTestTags.CloseButton)
+ }
+
+ // Interaction methods
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun isCloseButtonShown() {
+ closeButton.awaitDisplayed()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/AlternativeRoutingRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/AlternativeRoutingRobot.kt
new file mode 100644
index 0000000000..4bcecd4261
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/AlternativeRoutingRobot.kt
@@ -0,0 +1,58 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.settings
+
+import androidx.compose.ui.test.assertIsOff
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailsettings.presentation.settings.alternativerouting.TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+
+internal class AlternativeRoutingRobot : ComposeRobot() {
+
+ fun turnOffAlternativeRouting(): AlternativeRoutingRobot {
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM)
+ .performClick()
+ composeTestRule.waitUntil { AlternativeRoutingSettingIsToggled() }
+ return this
+ }
+
+ private fun AlternativeRoutingSettingIsToggled(): Boolean {
+ try {
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM)
+ .assertIsOff()
+ } catch (ignored: AssertionError) {
+ return false
+ }
+ return true
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun alternativeRoutingSettingIsToggled() {
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM)
+ .assertIsOff()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/CombinedContactsRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/CombinedContactsRobot.kt
new file mode 100644
index 0000000000..a244211038
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/CombinedContactsRobot.kt
@@ -0,0 +1,58 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.settings
+
+import androidx.compose.ui.test.assertIsOn
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailsettings.presentation.settings.combinedcontacts.TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+
+internal class CombinedContactsRobot : ComposeRobot() {
+
+ fun turnOnCombinedContacts(): CombinedContactsRobot {
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM)
+ .performClick()
+ composeTestRule.waitUntil { combinedContactsSettingIsToggled() }
+ return this
+ }
+
+ private fun combinedContactsSettingIsToggled(): Boolean {
+ try {
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM)
+ .assertIsOn()
+ } catch (ignored: AssertionError) {
+ return false
+ }
+ return true
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun combinedContactsSettingIsToggled() {
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM)
+ .assertIsOn()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/LanguageRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/LanguageRobot.kt
new file mode 100644
index 0000000000..acdd630362
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/LanguageRobot.kt
@@ -0,0 +1,137 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.settings
+
+import androidx.annotation.StringRes
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotSelected
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performScrollToNode
+import ch.protonmail.android.mailsettings.domain.model.AppLanguage
+import ch.protonmail.android.mailsettings.presentation.R.string
+import ch.protonmail.android.mailsettings.presentation.settings.language.TEST_TAG_LANG_SETTINGS_SCREEN_SCROLL_COL
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.util.onNodeWithText
+
+internal class LanguageRobot : ComposeRobot() {
+
+ fun selectBrazilianPortuguese(): LanguageRobot {
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_LANG_SETTINGS_SCREEN_SCROLL_COL)
+ .performScrollToNode(hasText(AppLanguage.PORTUGUESE_BRAZILIAN.langName))
+
+ composeTestRule
+ .onNodeWithText(AppLanguage.PORTUGUESE_BRAZILIAN.langName)
+ .performClick()
+
+ composeTestRule.waitForIdle()
+ return this
+ }
+
+ fun selectSpanish(): LanguageRobot {
+ composeTestRule
+ .onNodeWithText(AppLanguage.SPANISH.langName)
+ .performClick()
+ composeTestRule.waitForIdle()
+ return this
+ }
+
+ fun selectSystemDefault(): LanguageRobot {
+ composeTestRule
+ .onNodeWithText(string.mail_settings_system_default)
+ .performScrollTo()
+ .performClick()
+ composeTestRule.waitForIdle()
+ return this
+ }
+
+ fun selectSystemDefaultFromBrazilian(): LanguageRobot {
+ composeTestRule
+ .onNodeWithText("Padrão do sistema")
+ .performScrollTo()
+ .performClick()
+ composeTestRule.waitForIdle()
+ return this
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun appLanguageChangedToPortuguese() {
+ verifyScreenTitleMatchesText("Idioma do aplicativo")
+ }
+
+ fun appLanguageChangedToSpanish() {
+ verifyScreenTitleMatchesText("Idioma de la aplicación")
+ }
+
+ fun brazilianPortugueseLanguageIsSelected() {
+ verifyLanguageIsSelected(AppLanguage.PORTUGUESE_BRAZILIAN.langName)
+ }
+
+ fun spanishLanguageIsSelected() {
+ verifyLanguageIsSelected(AppLanguage.SPANISH.langName)
+ }
+
+ fun defaultLanguageIsSelected() {
+ verifyScreenTitleMatchesText(string.mail_settings_app_language)
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_system_default)
+ .assertIsDisplayed()
+ .assertIsSelected()
+
+ val languages = listOf("English", "Deutsch", "Français", "Nederlands", "Español (España)")
+ assertLanguagesAreShownButUnselected(languages)
+ }
+
+ private fun assertLanguagesAreShownButUnselected(languages: List) {
+ languages.forEach { language ->
+ composeTestRule
+ .onNodeWithText(language)
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+ }
+ }
+
+ private fun verifyLanguageIsSelected(text: String) {
+ composeTestRule
+ .onNodeWithText(text)
+ .assertIsSelected()
+ }
+
+ private fun verifyScreenTitleMatchesText(text: String) {
+ composeTestRule
+ .onNodeWithText(text)
+ .assertIsDisplayed()
+ }
+
+ private fun verifyScreenTitleMatchesText(@StringRes text: Int) {
+ composeTestRule
+ .onNodeWithText(text)
+ .assertIsDisplayed()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/SettingsRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/SettingsRobot.kt
new file mode 100644
index 0000000000..aee1fe762b
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/SettingsRobot.kt
@@ -0,0 +1,106 @@
+/*
+ * 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 .
+ */
+package ch.protonmail.android.uitest.robot.settings
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.hasScrollAction
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.onFirst
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollToNode
+import ch.protonmail.android.mailsettings.presentation.R.string
+import ch.protonmail.android.mailsettings.presentation.settings.SettingsScreenTestTags
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.robot.settings.account.AccountSettingsRobot
+import ch.protonmail.android.uitest.robot.settings.swipeactions.SwipeActionsRobot
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.awaitProgressIsHidden
+import ch.protonmail.android.uitest.util.hasText
+import ch.protonmail.android.uitest.util.onNodeWithText
+
+internal class SettingsRobot : ComposeRobot() {
+ private val rootItem = composeTestRule.onNodeWithTag(SettingsScreenTestTags.RootItem)
+
+ init {
+ rootItem.awaitDisplayed()
+ }
+
+ fun openLanguageSettings(): LanguageRobot {
+ composeTestRule
+ .onNodeWithText(string.mail_settings_app_language)
+ .performClick()
+ composeTestRule.waitForIdle()
+
+ return LanguageRobot()
+ }
+
+ fun openSwipeActions(): SwipeActionsRobot {
+ composeTestRule
+ .onList()
+ .performScrollToNode(hasText(string.mail_settings_swipe_actions))
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_swipe_actions)
+ .performClick()
+
+ composeTestRule.waitForIdle()
+
+ return SwipeActionsRobot()
+ }
+
+ fun openUserAccountSettings(): AccountSettingsRobot {
+ composeTestRule
+ .onNodeWithTag(SettingsScreenTestTags.AccountSettingsItem)
+ .performClick()
+
+ composeTestRule.awaitProgressIsHidden()
+
+ return AccountSettingsRobot()
+ }
+
+ fun openThemeSettings(): ThemeRobot {
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme)
+ .performClick()
+ composeTestRule.waitForIdle()
+
+ return ThemeRobot()
+ }
+
+ fun openCombinedContactsSettings(): CombinedContactsRobot {
+ composeTestRule
+ .onNodeWithText(string.mail_settings_combined_contacts)
+ .performClick()
+ composeTestRule.waitForIdle()
+
+ return CombinedContactsRobot()
+ }
+
+ fun openAlternativeRoutingSettings(): AlternativeRoutingRobot {
+ composeTestRule
+ .onNodeWithText(string.mail_settings_alternative_routing)
+ .performClick()
+ composeTestRule.waitForIdle()
+
+ return AlternativeRoutingRobot()
+ }
+
+ private fun ComposeTestRule.onList(): SemanticsNodeInteraction =
+ onAllNodes(hasScrollAction()).onFirst() // second is drawer
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/ThemeRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/ThemeRobot.kt
new file mode 100644
index 0000000000..2efbd118fb
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/ThemeRobot.kt
@@ -0,0 +1,97 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.settings
+
+import androidx.annotation.StringRes
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotSelected
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailsettings.presentation.R.string
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.util.onNodeWithText
+
+/**
+ * [ThemeRobot] class contains actions and verifications for ThemeSettingScreen
+ */
+internal class ThemeRobot : ComposeRobot() {
+
+ fun selectDarkTheme(): ThemeRobot {
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme_dark)
+ .performClick()
+ composeTestRule.waitUntil { optionWithTextIsSelected(composeTestRule, string.mail_settings_theme_dark) }
+ return this
+ }
+
+ fun selectSystemDefault(): ThemeRobot {
+ composeTestRule
+ .onNodeWithText(string.mail_settings_system_default)
+ .performClick()
+ composeTestRule.waitUntil { optionWithTextIsSelected(composeTestRule, string.mail_settings_system_default) }
+ return this
+ }
+
+ private fun optionWithTextIsSelected(
+ composeTestRule: ComposeTestRule,
+ @StringRes text: Int
+ ): Boolean {
+ try {
+ composeTestRule
+ .onNodeWithText(text)
+ .assertIsSelected()
+ } catch (ignored: AssertionError) {
+ return false
+ }
+ return true
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun darkThemeIsSelected() {
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme_dark)
+ .assertIsSelected()
+ }
+
+ fun defaultThemeSettingIsSelected() {
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme)
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_system_default)
+ .assertIsDisplayed()
+ .assertIsSelected()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme_light)
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme_dark)
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/account/AccountSettingsRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/account/AccountSettingsRobot.kt
new file mode 100644
index 0000000000..def2d62113
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/account/AccountSettingsRobot.kt
@@ -0,0 +1,78 @@
+/*
+ * 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 .
+ */
+package ch.protonmail.android.uitest.robot.settings.account
+
+import androidx.annotation.StringRes
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onChild
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollToNode
+import ch.protonmail.android.mailsettings.presentation.R.string
+import ch.protonmail.android.mailsettings.presentation.accountsettings.TEST_TAG_ACCOUNT_SETTINGS_LIST
+import ch.protonmail.android.mailsettings.presentation.accountsettings.TEST_TAG_ACCOUNT_SETTINGS_SCREEN
+import ch.protonmail.android.test.ksp.annotations.AsDsl
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.util.hasText
+import ch.protonmail.android.uitest.util.onNodeWithText
+import me.proton.core.test.android.robots.settings.PasswordManagementRobot
+
+@AsDsl
+internal class AccountSettingsRobot : ComposeRobot() {
+
+ fun openConversationMode(): ConversationModeRobot {
+ clickOnAccountListItemWithText(string.mail_settings_conversation_mode)
+ return ConversationModeRobot()
+ }
+
+ fun openPasswordManagement(): PasswordManagementRobot {
+ clickOnAccountListItemWithText(string.mail_settings_password_management)
+ return PasswordManagementRobot()
+ }
+
+ private fun clickOnAccountListItemWithText(@StringRes itemNameRes: Int) {
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ACCOUNT_SETTINGS_LIST)
+ .onChild()
+ .performScrollToNode(hasText(itemNameRes))
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(itemNameRes)
+ .performClick()
+ composeTestRule.waitForIdle()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun accountSettingsScreenIsDisplayed() {
+ composeTestRule.waitUntil(timeoutMillis = 5000) {
+ composeTestRule
+ .onAllNodesWithTag(TEST_TAG_ACCOUNT_SETTINGS_SCREEN)
+ .fetchSemanticsNodes(false)
+ .isNotEmpty()
+ }
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ACCOUNT_SETTINGS_SCREEN)
+ .assertIsDisplayed()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/account/ConversationModeRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/account/ConversationModeRobot.kt
new file mode 100644
index 0000000000..71b1d9b636
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/account/ConversationModeRobot.kt
@@ -0,0 +1,47 @@
+/*
+ * 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 .
+ */
+package ch.protonmail.android.uitest.robot.settings.account
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.onFirst
+import ch.protonmail.android.mailsettings.presentation.R
+import ch.protonmail.android.mailsettings.presentation.R.string
+import ch.protonmail.android.test.ksp.annotations.AsDsl
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.util.assertions.assertTextContains
+import ch.protonmail.android.uitest.util.onAllNodesWithText
+
+@AsDsl
+internal class ConversationModeRobot : ComposeRobot() {
+
+ @VerifiesOuter
+ inner class Verify {
+
+ fun conversationModeToggleIsDisplayedAndEnabled() {
+ composeTestRule
+ .onAllNodesWithText(R.string.mail_settings_conversation_mode)
+ // Take first as both "toolbar" and "switch" node are matching the same text
+ .onFirst()
+ .assertTextContains(string.mail_settings_conversation_mode_hint)
+ .assertIsDisplayed()
+ .assertIsEnabled()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/swipeactions/EditSwipeActionRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/swipeactions/EditSwipeActionRobot.kt
new file mode 100644
index 0000000000..117d0b66d7
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/swipeactions/EditSwipeActionRobot.kt
@@ -0,0 +1,53 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.settings.swipeactions
+
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailsettings.presentation.R.string
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.util.onAllNodesWithText
+import ch.protonmail.android.uitest.util.onNodeWithContentDescription
+import me.proton.core.presentation.compose.R.string as coreString
+
+internal class EditSwipeActionRobot : ComposeRobot() {
+
+ fun navigateUpToSwipeActions(): SwipeActionsRobot {
+ composeTestRule
+ .onNodeWithContentDescription(coreString.presentation_back)
+ .performClick()
+
+ return SwipeActionsRobot()
+ }
+
+ fun selectArchive(): EditSwipeActionRobot {
+ composeTestRule
+ .onAllNodesWithText(string.mail_settings_swipe_action_archive_description)[0]
+ .performClick()
+
+ return this
+ }
+
+ fun selectMarkRead(): EditSwipeActionRobot {
+ composeTestRule
+ .onAllNodesWithText(string.mail_settings_swipe_action_read_description)[0]
+ .performClick()
+
+ return this
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/swipeactions/SwipeActionsRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/swipeactions/SwipeActionsRobot.kt
new file mode 100644
index 0000000000..b4fb8b125f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/swipeactions/SwipeActionsRobot.kt
@@ -0,0 +1,85 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.robot.settings.swipeactions
+
+import androidx.annotation.StringRes
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailsettings.presentation.R.string
+import ch.protonmail.android.test.ksp.annotations.VerifiesOuter
+import ch.protonmail.android.uitest.robot.ComposeRobot
+import ch.protonmail.android.uitest.util.assertions.assertTextContains
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.onNodeWithText
+
+internal class SwipeActionsRobot : ComposeRobot() {
+
+ fun openSwipeLeft(): EditSwipeActionRobot {
+ composeTestRule
+ .onNodeWithText(string.mail_settings_swipe_left_name)
+ .awaitDisplayed()
+ .performClick()
+
+ return EditSwipeActionRobot()
+ }
+
+ fun openSwipeRight(): EditSwipeActionRobot {
+ composeTestRule
+ .onNodeWithText(string.mail_settings_swipe_right_name)
+ .awaitDisplayed()
+ .performClick()
+
+ return EditSwipeActionRobot()
+ }
+
+ @VerifiesOuter
+ inner class Verify {
+
+ inline fun swipeLeft(block: VerifySwipeAction.() -> Unit): VerifySwipeAction =
+ VerifySwipeAction(composeTestRule, composeTestRule.onNodeWithText(string.mail_settings_swipe_left_name))
+ .apply(block)
+
+ inline fun swipeRight(block: VerifySwipeAction.() -> Unit): VerifySwipeAction =
+ VerifySwipeAction(composeTestRule, composeTestRule.onNodeWithText(string.mail_settings_swipe_right_name))
+ .apply(block)
+
+ inner class VerifySwipeAction(
+ private val composeTestRule: ComposeTestRule,
+ private val interaction: SemanticsNodeInteraction
+ ) {
+
+ fun isArchive() {
+ assertHasText(string.mail_settings_swipe_action_archive_title)
+ }
+
+ fun isMarkRead() {
+ assertHasText(string.mail_settings_swipe_action_read_title)
+ }
+
+ private fun assertHasText(@StringRes textRes: Int) {
+ interaction
+ .awaitDisplayed()
+ .assertTextContains(textRes)
+ .assertIsDisplayed()
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/GrantNotificationsPermissionRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/GrantNotificationsPermissionRule.kt
new file mode 100644
index 0000000000..199940b98e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/GrantNotificationsPermissionRule.kt
@@ -0,0 +1,43 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.rule
+
+import android.Manifest
+import android.os.Build
+import ch.protonmail.android.uitest.util.InstrumentationHolder
+import org.junit.rules.ExternalResource
+
+/**
+ * A custom rule to allow Notifications Permission to be granted on API >= 33.
+ */
+internal class GrantNotificationsPermissionRule : ExternalResource() {
+
+ override fun before() {
+ super.before()
+
+ // Not needed before API 33, as Notifications Permission does not exist before Tiramisu.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
+
+ val instrumentation = InstrumentationHolder.instrumentation
+ val uiAutomation = instrumentation.uiAutomation
+ val packageName = instrumentation.targetContext.packageName
+
+ uiAutomation.grantRuntimePermission(packageName, Manifest.permission.POST_NOTIFICATIONS)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/HiltInjectRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/HiltInjectRule.kt
new file mode 100644
index 0000000000..2eebb86dd7
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/HiltInjectRule.kt
@@ -0,0 +1,29 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.rule
+
+import dagger.hilt.android.testing.HiltAndroidRule
+import org.junit.rules.ExternalResource
+
+class HiltInjectRule(private val rule: HiltAndroidRule) : ExternalResource() {
+
+ override fun before() {
+ rule.inject()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MainInitializerRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MainInitializerRule.kt
new file mode 100644
index 0000000000..bb7b1eba2a
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MainInitializerRule.kt
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.rule
+
+import androidx.test.core.app.ApplicationProvider
+import ch.protonmail.android.initializer.MainInitializer
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import org.junit.rules.ExternalResource
+
+/**
+ * A custom rule to initialize the [MainInitializer] before each test.
+ */
+class MainInitializerRule : ExternalResource() {
+
+ override fun before() {
+ super.before()
+ runBlocking {
+ withContext(Dispatchers.Main) { MainInitializer.init(ApplicationProvider.getApplicationContext()) }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockIntentsRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockIntentsRule.kt
new file mode 100644
index 0000000000..3bd399f4b8
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockIntentsRule.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.rule
+
+import android.app.Activity
+import android.app.Instrumentation.ActivityResult
+import android.content.Intent
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
+import org.junit.rules.ExternalResource
+
+internal class MockIntentsRule(private val captureIntents: Boolean) : ExternalResource() {
+
+ private val fakeActivityResult = ActivityResult(Activity.RESULT_OK, Intent())
+
+ override fun before() {
+ if (!captureIntents) return
+
+ Intents.init()
+
+ // Attachment viewing
+ Intents.intending(hasAction(Intent.ACTION_VIEW)).respondWith(fakeActivityResult)
+ }
+
+ override fun after() {
+ if (!captureIntents) return
+
+ Intents.release()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockOnboardingRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockOnboardingRule.kt
new file mode 100644
index 0000000000..d4a7e51005
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockOnboardingRule.kt
@@ -0,0 +1,33 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.rule
+
+import ch.protonmail.android.mailonboarding.data.local.OnboardingLocalDataSource
+import ch.protonmail.android.mailonboarding.domain.model.OnboardingPreference
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+
+internal class MockOnboardingRuntimeRule @Inject constructor(
+ private val onboardingLocalDataSource: OnboardingLocalDataSource
+) {
+
+ operator fun invoke(shouldForceShow: Boolean) = runBlocking {
+ onboardingLocalDataSource.save(OnboardingPreference(shouldForceShow))
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockTimeRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockTimeRule.kt
new file mode 100644
index 0000000000..05c293a0b8
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockTimeRule.kt
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.rule
+
+import java.time.Instant
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import org.junit.rules.ExternalResource
+
+/**
+ * A custom rule to allow mocking the [Instant] class when running tests.
+ */
+internal class MockTimeRule : ExternalResource() {
+
+ override fun before() {
+ super.before()
+ mockkStatic(Instant::class)
+ }
+
+ override fun after() {
+ unmockkAll()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/SpotlightSeenRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/SpotlightSeenRule.kt
new file mode 100644
index 0000000000..a06bf33b7d
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/SpotlightSeenRule.kt
@@ -0,0 +1,34 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.rule
+
+import ch.protonmail.android.mailsettings.domain.repository.LocalSpotlightEventsRepository
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+
+internal class SpotlightSeenRule @Inject constructor(
+ private val repo: LocalSpotlightEventsRepository
+) {
+
+ operator fun invoke(seen: Boolean) = runBlocking {
+ if (seen) {
+ repo.markCustomizeToolbarSeen()
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/common/BottomActionBarTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/common/BottomActionBarTest.kt
new file mode 100644
index 0000000000..8e6ca63da3
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/common/BottomActionBarTest.kt
@@ -0,0 +1,100 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.common
+
+import ch.protonmail.android.mailcommon.domain.model.Action
+import ch.protonmail.android.mailcommon.presentation.model.BottomBarState
+import ch.protonmail.android.mailcommon.presentation.ui.BottomActionBar
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.testdata.action.ActionUiModelTestData
+import ch.protonmail.android.uitest.robot.common.BottomActionBarRobot
+import ch.protonmail.android.uitest.robot.common.verify
+import dagger.hilt.android.testing.HiltAndroidTest
+import kotlinx.collections.immutable.toImmutableList
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+internal class BottomActionBarTest : HiltInstrumentedTest() {
+
+ @Test
+ fun whenBottomBarStateIsLoadingDisplayLoader() {
+ // given
+ val state = BottomBarState.Loading
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.verify { loaderIsDisplayed() }
+ }
+
+ @Test
+ fun whenBottomBarStateIsFailedLoadingActionsDisplayError() {
+ // given
+ val state = BottomBarState.Error.FailedLoadingActions
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.verify { failedLoadingErrorIsDisplayed() }
+ }
+
+ @Test
+ fun whenBottomBarStateIsDataUpToMaxActionsAreShowed() {
+ // given
+ val state = BottomBarState.Data.Shown(
+ listOf(
+ ActionUiModelTestData.star,
+ ActionUiModelTestData.delete,
+ ActionUiModelTestData.archive,
+ ActionUiModelTestData.move,
+ ActionUiModelTestData.label,
+ ActionUiModelTestData.markUnread,
+ ActionUiModelTestData.reportPhishing
+ ).toImmutableList()
+ )
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.verify {
+ errorAndLoaderHidden()
+
+ actionIsDisplayed(Action.Star)
+ actionIsDisplayed(Action.Delete)
+ actionIsDisplayed(Action.Archive)
+ actionIsDisplayed(Action.Move)
+ actionIsDisplayed(Action.Label)
+ actionIsDisplayed(Action.MarkUnread)
+
+ actionIsNotDisplayed(Action.ReportPhishing)
+ }
+ }
+
+ private fun setupScreen(
+ state: BottomBarState,
+ actions: BottomActionBar.Actions = BottomActionBar.Actions.Empty
+ ): BottomActionBarRobot = composeTestRule.BottomActionBarRobot {
+ BottomActionBar(state = state, viewActionCallbacks = actions)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/ConversationDetailScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/ConversationDetailScreenTest.kt
new file mode 100644
index 0000000000..e8ee11eb94
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/ConversationDetailScreenTest.kt
@@ -0,0 +1,474 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.detail
+
+import ch.protonmail.android.mailcommon.presentation.Effect
+import ch.protonmail.android.mailcommon.presentation.model.AvatarUiModel
+import ch.protonmail.android.mailcommon.presentation.model.BottomBarState
+import ch.protonmail.android.mailcommon.presentation.model.TextUiModel
+import ch.protonmail.android.mailcommon.presentation.sample.ActionUiModelSample
+import ch.protonmail.android.maildetail.presentation.model.ConversationDetailMessageUiModel
+import ch.protonmail.android.maildetail.presentation.model.ConversationDetailMetadataState
+import ch.protonmail.android.maildetail.presentation.model.ConversationDetailState
+import ch.protonmail.android.maildetail.presentation.model.ConversationDetailsMessagesState
+import ch.protonmail.android.maildetail.presentation.previewdata.ConversationDetailsPreviewData
+import ch.protonmail.android.maildetail.presentation.sample.ConversationDetailMessageUiModelSample
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreen
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection
+import ch.protonmail.android.uitest.robot.detail.section.conversation.messagesCollapsedSection
+import ch.protonmail.android.uitest.robot.detail.section.conversation.verify
+import ch.protonmail.android.uitest.robot.detail.section.detailTopBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+import ch.protonmail.android.uitest.robot.detail.verify
+import ch.protonmail.android.uitest.util.getString
+import dagger.hilt.android.testing.HiltAndroidTest
+import kotlinx.collections.immutable.toImmutableList
+import org.junit.Ignore
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+@Suppress("TooManyFunctions")
+@RegressionTest
+@HiltAndroidTest
+internal class ConversationDetailScreenTest : HiltInstrumentedTest() {
+
+ @Test
+ fun whenConversationIsLoadedThenSubjectIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds
+ val conversationState = state.conversationState as ConversationDetailMetadataState.Data
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.detailTopBarSection {
+ verify { hasSubject(conversationState.conversationUiModel.subject) }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedSenderInitialIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ val messagesState = state.messagesState as ConversationDetailsMessagesState.Data
+ when (val firstMessage = messagesState.messages.first()) {
+ is ConversationDetailMessageUiModel.Collapsed -> {
+ val initial = firstMessage.avatar as AvatarUiModel.ParticipantInitial
+ robot.messagesCollapsedSection {
+ verify { avatarInitialIsDisplayed(index = 0, text = initial.value) }
+ }
+ }
+
+ is ConversationDetailMessageUiModel.Expanded -> Unit
+ is ConversationDetailMessageUiModel.Expanding -> Unit
+ is ConversationDetailMessageUiModel.Hidden -> Unit
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedTimeIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.run {
+ val messagesState = state.messagesState as ConversationDetailsMessagesState.Data
+ when (val firstMessage = messagesState.messages.first()) {
+ is ConversationDetailMessageUiModel.Collapsed -> {
+ messagesCollapsedSection {
+ verify { timeIsDisplayed(index = 0, value = getString(firstMessage.shortTime)) }
+ }
+ }
+
+ is ConversationDetailMessageUiModel.Expanded -> {
+ messageHeaderSection {
+ verify {
+ hasTime(
+ value = getString(firstMessage.messageDetailHeaderUiModel.time)
+ )
+ }
+ }
+ }
+
+ is ConversationDetailMessageUiModel.Expanding -> Unit
+ is ConversationDetailMessageUiModel.Hidden -> Unit
+ }
+ }
+ }
+
+ @Test
+ fun whenDraftMessageIsLoadedDraftIconIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.Success.copy(
+ messagesState = ConversationDetailsMessagesState.Data(
+ messages = listOf(
+ ConversationDetailMessageUiModelSample.EmptyDraft
+ ).toImmutableList()
+ )
+ )
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.messagesCollapsedSection {
+ verify { avatarDraftIsDisplayed(index = 0) }
+ }
+ }
+
+ @Test
+ fun whenRepliedMessageIsLoadedRepliedIconIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.Success.copy(
+ messagesState = ConversationDetailsMessagesState.Data(
+ messages = listOf(
+ ConversationDetailMessageUiModelSample.InvoiceReplied
+ ).toImmutableList()
+ )
+ )
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.messagesCollapsedSection {
+ verify { repliedIconIsDisplayed(index = 0) }
+ }
+ }
+
+ @Test
+ fun whenRepliedAllMessageIsLoadedRepliedIconIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.Success.copy(
+ messagesState = ConversationDetailsMessagesState.Data(
+ messages = listOf(
+ ConversationDetailMessageUiModelSample.InvoiceRepliedAll
+ ).toImmutableList()
+ )
+ )
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.messagesCollapsedSection {
+ verify { repliedAllIconIsDisplayed(index = 0) }
+ }
+ }
+
+ @Test
+ fun whenForwardedMessageIsLoadedForwardedIconIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.Success.copy(
+ messagesState = ConversationDetailsMessagesState.Data(
+ messages = listOf(
+ ConversationDetailMessageUiModelSample.InvoiceForwarded
+ ).toImmutableList()
+ )
+ )
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.messagesCollapsedSection {
+ verify { forwardedIconIsDisplayed(index = 0) }
+ }
+ }
+
+ @Test
+ fun whenMessagesAreLoadedThenSenderIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.run {
+ val messagesState = state.messagesState as ConversationDetailsMessagesState.Data
+ when (val firstMessage = messagesState.messages.first()) {
+ is ConversationDetailMessageUiModel.Collapsed ->
+ messagesCollapsedSection {
+ verify { senderNameIsDisplayed(index = 0, value = firstMessage.sender.participantName) }
+ }
+
+ is ConversationDetailMessageUiModel.Expanded -> verify {
+ messageHeaderSection {
+ verify {
+ hasSenderName(firstMessage.messageDetailHeaderUiModel.sender.participantName)
+ }
+ }
+ }
+
+ is ConversationDetailMessageUiModel.Expanding -> Unit
+ is ConversationDetailMessageUiModel.Hidden -> Unit
+ }
+ }
+ }
+
+ @Test
+ fun whenMessageWithExpirationIsLoadedThenExpirationIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.Success.copy(
+ messagesState = ConversationDetailsMessagesState.Data(
+ messages = listOf(
+ ConversationDetailMessageUiModelSample.ExpiringInvitation
+ ).toImmutableList()
+ )
+ )
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.messagesCollapsedSection {
+ verify { expirationIsDisplayed(index = 0, value = "12h") }
+ }
+ }
+
+ @Test
+ fun whenStarredMessageIsLoadedThenStarIconIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.Success.copy(
+ messagesState = ConversationDetailsMessagesState.Data(
+ messages = listOf(
+ ConversationDetailMessageUiModelSample.StarredInvoice
+ ).toImmutableList()
+ )
+ )
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.messagesCollapsedSection {
+ verify {
+ starIconIsDisplayed(index = 0)
+ }
+ }
+ }
+
+ @Test
+ @Ignore("The component is correctly displayed, but the test fails to match it")
+ fun whenMessageWithAttachmentIsLoadedThenAttachmentIconIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.Success
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.messagesCollapsedSection {
+ verify {
+ attachmentIconIsDisplayed(index = 0)
+ }
+ }
+ }
+
+ @Test
+ fun whenTrashIsClickedThenActionIsCalled() {
+ // given
+ val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds.copy(
+ bottomBarState = BottomBarState.Data.Shown(
+ actions = listOf(ActionUiModelSample.Trash).toImmutableList()
+ )
+ )
+
+ // when
+ var trashClicked = false
+ val robot = setupScreen(
+ state = state,
+ actions = ConversationDetailScreen.Actions.Empty.copy(
+ onTrashClick = { trashClicked = true }
+ )
+ )
+
+ robot.bottomBarSection { moveToTrash() }
+
+ // then
+ assertTrue(trashClicked)
+ }
+
+ @Test
+ fun whenErrorThenErrorMessageIsDisplayed() {
+ // given
+ val message = "Something terrible happened!"
+ val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds.copy(
+ error = Effect.of(TextUiModel(message))
+ )
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.messageBodySection {
+ verify { loadingErrorMessageIsDisplayed(message) }
+ }
+ }
+
+ @Test
+ fun whenUnreadClickedThenCallbackIsInvoked() {
+ // given
+ val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds.copy(
+ bottomBarState = BottomBarState.Data.Shown(
+ actions = listOf(ActionUiModelSample.MarkUnread).toImmutableList()
+ )
+ )
+ var unreadClicked = false
+
+ // when
+ val robot = setupScreen(
+ state = state,
+ actions = ConversationDetailScreen.Actions.Empty.copy(
+ onUnreadClick = { unreadClicked = true }
+ )
+ )
+
+ robot.bottomBarSection { markAsUnread() }
+
+ // then
+ assertTrue(unreadClicked)
+ }
+
+ @Test
+ fun whenExitStateThenCallbackIsInvoked() {
+ // given
+ val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds.copy(
+ exitScreenEffect = Effect.of(Unit)
+ )
+ var didExit = false
+
+ // when
+ setupScreen(
+ state = state,
+ actions = ConversationDetailScreen.Actions.Empty.copy(
+ onExit = { didExit = true }
+ )
+ )
+
+ // then
+ assertTrue(didExit)
+ }
+
+ @Test
+ fun whenOfflineStateThenOfflineErrorMessageIsDisplayed() {
+ // given
+ val message = "You're offline. Please go back online to load messages"
+ val state = ConversationDetailsPreviewData.Success.copy(
+ messagesState = ConversationDetailsMessagesState.Offline
+ )
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.messageBodySection {
+ verify { loadingErrorMessageIsDisplayed(message) }
+ }
+ }
+
+ @Test
+ fun whenConversationWithExpandedMessagesIsLoadedThenMessageHeaderIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.Success.copy(
+ messagesState = ConversationDetailsMessagesState.Data(
+ messages = listOf(
+ ConversationDetailMessageUiModelSample.InvoiceWithLabelExpanded
+ ).toImmutableList()
+ )
+ )
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.messageHeaderSection {
+ verify { headerIsDisplayed() }
+ }
+ }
+
+ @Test
+ fun whenConversationWithExpandedMessagesIsLoadedThenMessageBodyIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.Success.copy(
+ messagesState = ConversationDetailsMessagesState.Data(
+ messages = listOf(
+ ConversationDetailMessageUiModelSample.InvoiceWithLabelExpanded
+ ).toImmutableList()
+ )
+ )
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.messageBodySection {
+ verify {
+ messageInWebViewContains(
+ ConversationDetailMessageUiModelSample.InvoiceWithLabelExpanded.messageBodyUiModel.messageBody
+ )
+ }
+ }
+ }
+
+ @Test
+ fun whenConversationWithExpandedMessagesIsLoadedThenTheCollapsedHeaderIsNotDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.Success.copy(
+ messagesState = ConversationDetailsMessagesState.Data(
+ messages = listOf(
+ ConversationDetailMessageUiModelSample.InvoiceWithLabelExpanded
+ ).toImmutableList()
+ )
+ )
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.messagesCollapsedSection {
+ verify { collapsedHeaderIsNotDisplayed() }
+ }
+ }
+
+ private fun setupScreen(
+ state: ConversationDetailState,
+ actions: ConversationDetailScreen.Actions = ConversationDetailScreen.Actions.Empty
+ ): ConversationDetailRobot = conversationDetailRobot {
+ this@ConversationDetailScreenTest.composeTestRule.setContent {
+ ConversationDetailScreen(state = state, actions = actions, scrollToMessageId = null)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/DetailScreenTopBarTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/DetailScreenTopBarTest.kt
new file mode 100644
index 0000000000..e1869926bf
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/DetailScreenTopBarTest.kt
@@ -0,0 +1,120 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.detail
+
+import ch.protonmail.android.maildetail.presentation.model.ConversationDetailMetadataState
+import ch.protonmail.android.maildetail.presentation.model.ConversationDetailState
+import ch.protonmail.android.maildetail.presentation.model.MessageDetailState
+import ch.protonmail.android.maildetail.presentation.model.MessageMetadataState
+import ch.protonmail.android.maildetail.presentation.previewdata.ConversationDetailsPreviewData
+import ch.protonmail.android.maildetail.presentation.previewdata.MessageDetailsPreviewData
+import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreen
+import ch.protonmail.android.maildetail.presentation.ui.DetailScreenTopBar
+import ch.protonmail.android.maildetail.presentation.ui.MessageDetailScreen
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.detailTopBarSection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+import dagger.hilt.android.testing.HiltAndroidTest
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+internal class DetailScreenTopBarTest : HiltInstrumentedTest() {
+
+ @Test
+ fun whenConversationIsLoadingThenSubjectContainsABlankString() {
+ // given
+ val state = ConversationDetailsPreviewData.Loading
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.detailTopBarSection {
+ verify { hasSubject(DetailScreenTopBar.NoTitle) }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadingThenSubjectContainsABlankString() {
+ // given
+ val state = MessageDetailsPreviewData.Loading
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.detailTopBarSection {
+ verify { hasSubject(DetailScreenTopBar.NoTitle) }
+ }
+ }
+
+ @Test
+ fun whenConversationIsLoadedThenSubjectIsDisplayed() {
+ // given
+ val state = ConversationDetailsPreviewData.Success
+ val conversationState = state.conversationState as ConversationDetailMetadataState.Data
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.detailTopBarSection {
+ verify { hasSubject(conversationState.conversationUiModel.subject) }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedThenSubjectIsDisplayed() {
+ // given
+ val state = MessageDetailsPreviewData.Message
+ val messageState = state.messageMetadataState as MessageMetadataState.Data
+
+ // when
+ val robot = setupScreen(state = state)
+
+ // then
+ robot.detailTopBarSection {
+ verify { hasSubject(messageState.messageDetailActionBar.subject) }
+ }
+ }
+
+ private fun setupScreen(
+ state: ConversationDetailState,
+ actions: ConversationDetailScreen.Actions = ConversationDetailScreen.Actions.Empty
+ ): ConversationDetailRobot = conversationDetailRobot {
+ this@DetailScreenTopBarTest.composeTestRule.setContent {
+ ConversationDetailScreen(state = state, actions = actions, scrollToMessageId = null)
+ }
+ }
+
+ private fun setupScreen(
+ state: MessageDetailState,
+ actions: MessageDetailScreen.Actions = MessageDetailScreen.Actions.Empty
+ ): MessageDetailRobot = messageDetailRobot {
+ this@DetailScreenTopBarTest.composeTestRule.setContent {
+ MessageDetailScreen(state = state, actions = actions)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageBodyTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageBodyTest.kt
new file mode 100644
index 0000000000..b9f765b5fd
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageBodyTest.kt
@@ -0,0 +1,104 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.detail
+
+import java.util.UUID
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailcommon.domain.system.DeviceCapabilities.Capabilities
+import ch.protonmail.android.mailcommon.presentation.system.LocalDeviceCapabilitiesProvider
+import ch.protonmail.android.maildetail.presentation.sample.MessageDetailBodyUiModelSample
+import ch.protonmail.android.maildetail.presentation.ui.MessageBody
+import ch.protonmail.android.maildetail.presentation.ui.MessageBodyTestTags
+import ch.protonmail.android.mailmessage.presentation.model.MessageBodyExpandCollapseMode
+import ch.protonmail.android.mailmessage.presentation.ui.MessageBodyWebViewTestTags
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import dagger.hilt.android.testing.HiltAndroidTest
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+class MessageBodyTest : HiltInstrumentedTest() {
+
+ @Test
+ fun shouldDisplayWebViewIfAvailable() {
+ // given
+ val messageContent = UUID.randomUUID().toString()
+ val state = MessageDetailBodyUiModelSample.build(messageContent)
+
+ // when
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalDeviceCapabilitiesProvider provides Capabilities(hasWebView = true)) {
+ MessageBody(
+ modifier = Modifier,
+ messageBodyUiModel = state,
+ actions = EmptyActions,
+ expandCollapseMode = MessageBodyExpandCollapseMode.NotApplicable
+ )
+ }
+ }
+
+ // then
+ composeTestRule.onNodeWithTag(MessageBodyWebViewTestTags.WebView).assertExists()
+ composeTestRule.onNodeWithTag(MessageBodyTestTags.WebViewAlternative).assertDoesNotExist()
+ }
+
+ @Test
+ fun shouldDisplayWebViewAlternativeWhenWebViewNotAvailable() {
+ // given
+ val messageContent = UUID.randomUUID().toString()
+ val state = MessageDetailBodyUiModelSample.build(messageContent)
+
+ // when
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalDeviceCapabilitiesProvider provides Capabilities(hasWebView = false)) {
+ MessageBody(
+ modifier = Modifier,
+ messageBodyUiModel = state,
+ actions = EmptyActions,
+ expandCollapseMode = MessageBodyExpandCollapseMode.NotApplicable
+ )
+ }
+ }
+
+ // then
+ composeTestRule.onNodeWithTag(MessageBodyWebViewTestTags.WebView).assertDoesNotExist()
+ composeTestRule.onNodeWithTag(MessageBodyTestTags.WebViewAlternative).assertExists()
+ }
+}
+
+private val EmptyActions = MessageBody.Actions(
+ onExpandCollapseButtonClicked = {},
+ onMessageBodyLinkClicked = {},
+ onShowAllAttachments = {},
+ onAttachmentClicked = {},
+ loadEmbeddedImage = { _, _ -> null },
+ onReply = {},
+ onReplyAll = {},
+ onForward = {},
+ onEffectConsumed = { _, _ -> },
+ onLoadRemoteContent = {},
+ onLoadEmbeddedImages = {},
+ onLoadRemoteAndEmbeddedContent = {},
+ onOpenInProtonCalendar = {},
+ onPrint = {},
+ onViewEntireMessageClicked = { _, _, _, _ -> },
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageDetailHeaderTestData.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageDetailHeaderTestData.kt
new file mode 100644
index 0000000000..2e0d16d804
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageDetailHeaderTestData.kt
@@ -0,0 +1,59 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.detail
+
+import ch.protonmail.android.maildetail.presentation.R
+import ch.protonmail.android.maildetail.presentation.model.MessageMetadataState
+import ch.protonmail.android.maildetail.presentation.model.ParticipantUiModel
+import ch.protonmail.android.maildetail.presentation.previewdata.MessageDetailsPreviewData
+import kotlinx.collections.immutable.toImmutableList
+
+internal object MessageDetailHeaderTestData {
+
+ val MessageWithOneRecipient = BaseMessage.copy(
+ messageMetadataState = BaseMetadataState.copy(messageDetailHeader = HeaderWithOneRecipient)
+ )
+
+ val MessageWithMultipleRecipients = BaseMessage.copy(
+ messageMetadataState = BaseMetadataState.copy(messageDetailHeader = HeaderWithMultipleRecipients)
+ )
+}
+
+private val BaseParticipantUiModel = ParticipantUiModel(
+ "one",
+ "address1@proton.me",
+ participantPadlock = R.drawable.ic_proton_lock,
+ shouldShowOfficialBadge = false
+)
+
+private val BaseMessage = MessageDetailsPreviewData.Message
+private val BaseMetadataState = BaseMessage.messageMetadataState as MessageMetadataState.Data
+private val BaseMessageDetailHeader = BaseMetadataState.messageDetailHeader
+
+private val HeaderWithOneRecipient = BaseMessageDetailHeader.copy(
+ toRecipients = listOf(BaseParticipantUiModel).toImmutableList(),
+ ccRecipients = emptyList().toImmutableList(),
+ bccRecipients = emptyList().toImmutableList()
+)
+
+private val HeaderWithMultipleRecipients = BaseMessageDetailHeader.copy(
+ toRecipients = listOf(BaseParticipantUiModel).toImmutableList(),
+ ccRecipients = listOf(BaseParticipantUiModel.copy(participantAddress = "address2@proton.me")).toImmutableList(),
+ bccRecipients = emptyList().toImmutableList()
+)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageDetailScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageDetailScreenTest.kt
new file mode 100644
index 0000000000..bf3729b0a0
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageDetailScreenTest.kt
@@ -0,0 +1,460 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.detail
+
+import android.net.Uri
+import ch.protonmail.android.mailcommon.presentation.Effect
+import ch.protonmail.android.mailcommon.presentation.model.AvatarUiModel
+import ch.protonmail.android.mailcommon.presentation.model.BottomBarState
+import ch.protonmail.android.mailcommon.presentation.model.TextUiModel
+import ch.protonmail.android.mailcommon.presentation.sample.ActionUiModelSample
+import ch.protonmail.android.maildetail.presentation.R
+import ch.protonmail.android.maildetail.presentation.model.MessageBodyState
+import ch.protonmail.android.maildetail.presentation.model.MessageDetailState
+import ch.protonmail.android.maildetail.presentation.model.MessageMetadataState
+import ch.protonmail.android.maildetail.presentation.previewdata.MessageDetailsPreviewData
+import ch.protonmail.android.maildetail.presentation.ui.MessageDetailScreen
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.testdata.message.MessageBodyUiModelTestData
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.detail.ExtendedHeaderRecipientEntry
+import ch.protonmail.android.uitest.models.labels.LabelEntry
+import ch.protonmail.android.uitest.robot.detail.messageDetailRobot
+import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection
+import ch.protonmail.android.uitest.robot.detail.section.messageBodySection
+import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection
+import ch.protonmail.android.uitest.robot.detail.section.verify
+import dagger.hilt.android.testing.HiltAndroidTest
+import kotlinx.collections.immutable.toImmutableList
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+@RegressionTest
+@HiltAndroidTest
+internal class MessageDetailScreenTest : HiltInstrumentedTest() {
+
+ @Test
+ fun whenMessageIsLoadedThenMessageHeaderIsDisplayed() {
+ // given
+ val state = MessageDetailsPreviewData.Message
+
+ // when
+ val robot = messageDetailRobot { setUpScreen(state = state) }
+
+ // then
+ robot.messageHeaderSection {
+ verify { headerIsDisplayed() }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedThenAvatarIsDisplayedInMessageHeader() {
+ // given
+ val state = MessageDetailsPreviewData.Message
+ val messageState = state.messageMetadataState as MessageMetadataState.Data
+ val avatarInitial = (messageState.messageDetailHeader.avatar as AvatarUiModel.ParticipantInitial).run {
+ AvatarInitial.WithText(value)
+ }
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageHeaderSection {
+ verify { hasAvatarInitial(avatarInitial) }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedThenSenderNameIsDisplayedInMessageHeader() {
+ // given
+ val state = MessageDetailsPreviewData.Message
+ val messageState = state.messageMetadataState as MessageMetadataState.Data
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageHeaderSection {
+ verify { hasSenderName(messageState.messageDetailHeader.sender.participantName) }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedThenSenderAddressIsDisplayedInMessageHeader() {
+ // given
+ val state = MessageDetailsPreviewData.Message
+ val messageState = state.messageMetadataState as MessageMetadataState.Data
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageHeaderSection {
+ verify { hasSenderAddress(messageState.messageDetailHeader.sender.participantAddress) }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedThenTimeIsDisplayedInMessageHeader() {
+ // given
+ val state = MessageDetailsPreviewData.Message
+ val messageState = state.messageMetadataState as MessageMetadataState.Data
+ val time = messageState.messageDetailHeader.time as TextUiModel.Text
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageHeaderSection {
+ verify { hasTime(time.value) }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedThenRecipientsAreDisplayedInMessageHeader() {
+ // given
+ val state = MessageDetailsPreviewData.Message
+ val messageState = state.messageMetadataState as MessageMetadataState.Data
+ val recipients = messageState.messageDetailHeader.allRecipients as TextUiModel.Text
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageHeaderSection {
+ verify { hasRecipient(recipients.value) }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedAndMessageHeaderIsClickedThenExpandedRecipientsAreDisplayed() {
+ // given
+ val state = MessageDetailsPreviewData.Message
+ val messageState = state.messageMetadataState as MessageMetadataState.Data
+ val recipients = messageState.messageDetailHeader.toRecipients.mapIndexed { idx: Int, element ->
+ ExtendedHeaderRecipientEntry.To(index = idx, element.participantName, element.participantAddress)
+ }.toTypedArray()
+
+ val robot = setUpScreen(state = state)
+
+ // when
+ robot.messageHeaderSection { expandHeader() }
+
+ // then
+ robot.messageHeaderSection {
+ expanded {
+ verify { hasRecipients(*recipients) }
+ }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedAndMessageHeaderIsClickedThenExtendedTimeIsShown() {
+ // given
+ val state = MessageDetailsPreviewData.Message
+ val messageState = state.messageMetadataState as MessageMetadataState.Data
+ val time = messageState.messageDetailHeader.extendedTime as TextUiModel.Text
+ val robot = setUpScreen(state = state)
+
+ // when
+ robot.messageHeaderSection { expandHeader() }
+
+ // then
+ robot.messageHeaderSection {
+ expanded {
+ verify { hasTime(time.value) }
+ }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedAndMessageHeaderIsClickedThenLocationNameIsShown() {
+ // given
+ val state = MessageDetailsPreviewData.Message
+ val messageState = state.messageMetadataState as MessageMetadataState.Data
+ val robot = setUpScreen(state = state)
+
+ // when
+ robot.messageHeaderSection { expandHeader() }
+
+ // then
+ robot.messageHeaderSection {
+ expanded {
+ verify { hasLocation(messageState.messageDetailHeader.location.name) }
+ }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedAndMessageHeaderIsClickedThenMessageSizeIsShown() {
+ // given
+ val state = MessageDetailsPreviewData.Message
+ val messageState = state.messageMetadataState as MessageMetadataState.Data
+ val robot = setUpScreen(state = state)
+
+ // when
+ robot.messageHeaderSection { expandHeader() }
+
+ // then
+ robot.messageHeaderSection {
+ expanded {
+ verify { hasSize(messageState.messageDetailHeader.size) }
+ }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedAndHasOneRecipientThenReplyQuickActionButtonIsShown() {
+ // given
+ val state = MessageDetailHeaderTestData.MessageWithOneRecipient
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageHeaderSection {
+ verify { hasReplyButton() }
+ }
+ }
+
+ @Test
+ fun whenMessageIsLoadedAndHasMoreThanOneRecipientThenReplyAllQuickActionButtonIsShown() {
+ // given
+ val state = MessageDetailHeaderTestData.MessageWithMultipleRecipients
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageHeaderSection {
+ verify { hasReplyAllButton() }
+ }
+ }
+
+ @Test
+ fun whenTrashIsClickedThenActionIsCalled() {
+ // given
+ val state = MessageDetailsPreviewData.Message.copy(
+ bottomBarState = BottomBarState.Data.Shown(
+ actions = listOf(ActionUiModelSample.Trash).toImmutableList()
+ )
+ )
+
+ var trashClicked = false
+
+ val robot = setUpScreen(
+ state = state,
+ actions = MessageDetailScreen.Actions.Empty.copy(
+ onTrashClick = { trashClicked = true }
+ )
+ )
+
+ // when
+ robot.bottomBarSection { moveToTrash() }
+
+ // then
+ assertTrue(trashClicked)
+ }
+
+ @Test
+ fun whenMessageWithLabelsIsLoadedThenFirstLabelIsDisplayed() {
+ // given
+ val state = MessageDetailsPreviewData.MessageWithLabels
+ val label = (state.messageMetadataState as MessageMetadataState.Data).messageDetailHeader.labels.first()
+ val labelEntry = LabelEntry(index = 0, text = label.name)
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageHeaderSection {
+ verify { hasLabels(labelEntry) }
+ }
+ }
+
+ @Test
+ fun whenUnreadClickedThenCallbackIsInvoked() {
+ // given
+ val state = MessageDetailsPreviewData.Message.copy(
+ bottomBarState = BottomBarState.Data.Shown(
+ actions = listOf(ActionUiModelSample.MarkUnread).toImmutableList()
+ )
+ )
+ var unreadClicked = false
+
+ // when
+ val robot = setUpScreen(
+ state = state,
+ actions = MessageDetailScreen.Actions.Empty.copy(
+ onUnreadClick = { unreadClicked = true }
+ )
+ )
+
+ robot.bottomBarSection { markAsUnread() }
+
+ // then
+ assertTrue(unreadClicked)
+ }
+
+ @Test
+ fun whenExitStateThenCallbackIsInvoked() {
+ // given
+ val state = MessageDetailsPreviewData.Message.copy(
+ exitScreenEffect = Effect.of(Unit)
+ )
+ var didExit = false
+
+ // when
+ setUpScreen(
+ state = state,
+ actions = MessageDetailScreen.Actions.Empty.copy(
+ onExit = { didExit = true }
+ )
+ )
+
+ // then
+ assertTrue(didExit)
+ }
+
+ @Test
+ fun whenPlainTextMessageBodyIsLoadedThenPlainTextMessageBodyIsDisplayedInWebView() {
+ // given
+ val state = MessageDetailsPreviewData.Message
+ val messageBody = (state.messageBodyState as MessageBodyState.Data).messageBodyUiModel.messageBodyWithoutQuote
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageBodySection {
+ verify { messageInWebViewContains(messageBody) }
+ }
+ }
+
+ @Test
+ fun whenHtmlMessageBodyIsLoadedThenHtmlMessageBodyIsDisplayedInWebView() {
+ // given
+ val state = MessageDetailsPreviewData.Message.copy(
+ messageBodyState = MessageBodyState.Data(MessageBodyUiModelTestData.htmlMessageBodyUiModel)
+ )
+ val messageBody = """
+ Dear Test,
+ This is an HTML message body.
+ Kind regards,
+ Developer
+ """.trimIndent()
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageBodySection {
+ verify { messageInWebViewContains(messageBody, tagName = "div") }
+ }
+ }
+
+ @Test
+ fun whenMessageBodyLoadingFailedWithNoNetworkThenErrorMessageIsShown() {
+ // given
+ val state = MessageDetailsPreviewData.Message.copy(
+ messageBodyState = MessageBodyState.Error.Data(isNetworkError = true)
+ )
+ val errorMessage = R.string.error_offline_loading_message
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageBodySection {
+ verify { loadingErrorMessageIsDisplayed(errorMessage) }
+ }
+ }
+
+ @Test
+ fun whenMessageBodyLoadingFailedThenErrorMessageAndReloadButtonIsShown() {
+ // given
+ val state = MessageDetailsPreviewData.Message.copy(
+ messageBodyState = MessageBodyState.Error.Data(isNetworkError = false)
+ )
+ val errorMessage = R.string.error_loading_message
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageBodySection {
+ verify {
+ loadingErrorMessageIsDisplayed(errorMessage)
+ bodyReloadButtonIsDisplayed()
+ }
+ }
+ }
+
+ @Test
+ fun whenMessageBodyDecryptionFailedThenEncryptedBodyAndErrorMessageAreShown() {
+ // given
+ val state = MessageDetailsPreviewData.Message.copy(
+ messageBodyState = MessageBodyState.Error.Decryption(MessageBodyUiModelTestData.plainTextMessageBodyUiModel)
+ )
+ val messageBody = (state.messageBodyState as MessageBodyState.Error.Decryption).encryptedMessageBody.messageBody
+
+ // when
+ val robot = setUpScreen(state = state)
+
+ // then
+ robot.messageBodySection {
+ verify {
+ bodyDecryptionErrorMessageIsDisplayed()
+ messageInWebViewContains(messageBody)
+ }
+ }
+ }
+
+ @Test
+ fun whenMessageBodyLinkWasClickedThenCallbackIsInvoked() {
+ // Given
+ val uri = Uri.EMPTY
+ val state = MessageDetailsPreviewData.Message.copy(
+ openMessageBodyLinkEffect = Effect.of(uri)
+ )
+ var isMessageBodyLinkOpened = false
+
+ // When
+ setUpScreen(
+ state = state,
+ actions = MessageDetailScreen.Actions.Empty.copy(
+ onOpenMessageBodyLink = { isMessageBodyLinkOpened = true }
+ )
+ )
+
+ // Then
+ assertTrue(isMessageBodyLinkOpened)
+ }
+
+ private fun setUpScreen(
+ state: MessageDetailState,
+ actions: MessageDetailScreen.Actions = MessageDetailScreen.Actions.Empty
+ ) = messageDetailRobot {
+ this@MessageDetailScreenTest.composeTestRule.setContent {
+ MessageDetailScreen(state = state, actions = actions)
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxItemLabelsTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxItemLabelsTest.kt
new file mode 100644
index 0000000000..7a04357201
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxItemLabelsTest.kt
@@ -0,0 +1,59 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.mailbox
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import ch.protonmail.android.maillabel.presentation.model.LabelUiModel
+import ch.protonmail.android.maillabel.presentation.previewdata.MailboxItemLabelsPreviewData
+import ch.protonmail.android.maillabel.presentation.ui.LabelsList
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.theme.ProtonTheme
+import kotlin.test.Test
+
+@RegressionTest
+@HiltAndroidTest
+internal class MailboxItemLabelsTest : HiltInstrumentedTest() {
+
+ @Test
+ fun whenAllLabelsCanFitTheScreenShowsThemEntirely() {
+
+ // given
+ val labels = MailboxItemLabelsPreviewData.ThreeItems
+
+ // when
+ setupWithState(labels)
+
+ // then
+ for (label in labels) {
+ composeTestRule.onNodeWithText(label.name)
+ .assertIsDisplayed()
+ }
+ }
+
+ private fun setupWithState(labels: List) {
+ composeTestRule.setContent {
+ ProtonTheme {
+ LabelsList(labels = labels)
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxScreenTest.kt
new file mode 100644
index 0000000000..35f4c0bac4
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxScreenTest.kt
@@ -0,0 +1,284 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.mailbox
+
+import androidx.compose.runtime.Composable
+import androidx.paging.PagingData
+import androidx.paging.compose.collectAsLazyPagingItems
+import arrow.core.nonEmptyListOf
+import ch.protonmail.android.mailcommon.presentation.Effect
+import ch.protonmail.android.mailcommon.presentation.model.ActionUiModel
+import ch.protonmail.android.mailcommon.presentation.model.BottomBarState
+import ch.protonmail.android.mailcommon.presentation.ui.delete.DeleteDialogState
+import ch.protonmail.android.maillabel.domain.model.MailLabel
+import ch.protonmail.android.maillabel.domain.model.MailLabelId
+import ch.protonmail.android.maillabel.presentation.sample.LabelUiModelSample
+import ch.protonmail.android.maillabel.presentation.text
+import ch.protonmail.android.mailmailbox.domain.model.MailboxItemType
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreen
+import ch.protonmail.android.mailmailbox.presentation.mailbox.model.MailboxItemUiModel
+import ch.protonmail.android.mailmailbox.presentation.mailbox.model.MailboxListState
+import ch.protonmail.android.mailmailbox.presentation.mailbox.model.MailboxState
+import ch.protonmail.android.mailmailbox.presentation.mailbox.model.MailboxTopAppBarState
+import ch.protonmail.android.mailmailbox.presentation.mailbox.model.StorageLimitState
+import ch.protonmail.android.mailmailbox.presentation.mailbox.model.UnreadFilterState
+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.mailnotifications.presentation.model.NotificationPermissionDialogState
+import ch.protonmail.android.mailsettings.presentation.accountsettings.autodelete.AutoDeleteSettingState
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.testdata.mailbox.MailboxItemUiModelTestData
+import ch.protonmail.android.uitest.models.avatar.AvatarInitial
+import ch.protonmail.android.uitest.models.folders.MailLabelEntry
+import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry
+import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry
+import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot
+import ch.protonmail.android.uitest.robot.mailbox.section.emptyListSection
+import ch.protonmail.android.uitest.robot.mailbox.section.listSection
+import ch.protonmail.android.uitest.robot.mailbox.section.progressListSection
+import ch.protonmail.android.uitest.robot.mailbox.section.verify
+import ch.protonmail.android.uitest.util.ManagedState
+import ch.protonmail.android.uitest.util.StateManager
+import dagger.hilt.android.testing.HiltAndroidTest
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.flowOf
+import org.junit.Ignore
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+internal class MailboxScreenTest : HiltInstrumentedTest() {
+
+ private val topMailboxItem = MailboxListItemEntry(
+ index = 0,
+ avatarInitial = AvatarInitial.WithText("T"),
+ participants = listOf(ParticipantEntry.NoSender),
+ subject = "1",
+ date = "10:42"
+ )
+
+ @Test
+ fun whenLoadingThenProgressIsDisplayed() {
+ val mailboxState = MailboxStateSampleData.Loading
+ val robot = setupScreen(state = mailboxState)
+
+ robot.progressListSection { verify { isShown() } }
+ }
+
+ @Test
+ fun whenLoadingCompletedThenItemsAreDisplayed() {
+ val mailboxListState = MailboxListState.Data.ViewMode(
+ currentMailLabel = MailLabel.System(MailLabelId.System.Inbox),
+ openItemEffect = Effect.empty(),
+ scrollToMailboxTop = Effect.empty(),
+ offlineEffect = Effect.empty(),
+ refreshErrorEffect = Effect.empty(),
+ refreshRequested = false,
+ swipingEnabled = false,
+ swipeActions = null,
+ searchState = MailboxSearchStateSampleData.NotSearching,
+ clearState = MailboxListState.Data.ClearState.Hidden,
+ autoDeleteBannerState = MailboxListState.Data.AutoDeleteBannerState.Hidden
+ )
+ val mailboxState = MailboxStateSampleData.Loading.copy(mailboxListState = mailboxListState)
+ val items = listOf(MailboxItemUiModelTestData.readMailboxItemUiModel)
+ val robot = setupScreen(state = mailboxState, items = items)
+
+ robot.listSection {
+ verify {
+ listItemsAreShown(topMailboxItem.copy(subject = items.first().subject))
+ }
+ }
+ }
+
+ @Test
+ fun whenLoadingCompletedThenItemsLabelsAreDisplayed() {
+ val mailboxListState = MailboxListState.Data.ViewMode(
+ currentMailLabel = MailLabel.System(MailLabelId.System.Inbox),
+ openItemEffect = Effect.empty(),
+ scrollToMailboxTop = Effect.empty(),
+ offlineEffect = Effect.empty(),
+ refreshErrorEffect = Effect.empty(),
+ refreshRequested = false,
+ swipingEnabled = false,
+ swipeActions = null,
+ searchState = MailboxSearchStateSampleData.NotSearching,
+ clearState = MailboxListState.Data.ClearState.Hidden,
+ autoDeleteBannerState = MailboxListState.Data.AutoDeleteBannerState.Hidden
+ )
+ val mailboxState = MailboxStateSampleData.Loading.copy(mailboxListState = mailboxListState)
+ val label = LabelUiModelSample.News
+ val item = MailboxItemUiModelTestData.buildMailboxUiModelItem(
+ labels = persistentListOf(label)
+ )
+ val mailboxItem = topMailboxItem.copy(
+ labels = listOf(MailLabelEntry(index = 0, name = label.name)),
+ subject = "0"
+ )
+
+ val robot = setupScreen(state = mailboxState, items = listOf(item))
+
+ robot.listSection { verify { listItemsAreShown(mailboxItem) } }
+ }
+
+ @Test
+ @Ignore(
+ """
+ The current version of the paging library doesn't allow us to test this in the same way.
+ Wee need to find an alternative
+ """
+ ) // MAILANDR-330
+ fun givenLoadingCompletedWhenNoItemThenEmptyMailboxIsDisplayed() {
+ val mailboxListState = MailboxListState.Data.ViewMode(
+ currentMailLabel = MailLabel.System(MailLabelId.System.Inbox),
+ openItemEffect = Effect.empty(),
+ scrollToMailboxTop = Effect.empty(),
+ offlineEffect = Effect.empty(),
+ refreshErrorEffect = Effect.empty(),
+ refreshRequested = false,
+ swipingEnabled = false,
+ swipeActions = null,
+ searchState = MailboxSearchStateSampleData.NotSearching,
+ clearState = MailboxListState.Data.ClearState.Hidden,
+ autoDeleteBannerState = MailboxListState.Data.AutoDeleteBannerState.Hidden
+ )
+ val mailboxState = MailboxStateSampleData.Loading.copy(mailboxListState = mailboxListState)
+ val robot = setupScreen(state = mailboxState)
+
+ robot.emptyListSection { verify { isShown() } }
+ }
+
+ @Test
+ @Ignore("How to verify SwipeRefresh is refreshing?") // MAILANDR-330
+ fun givenEmptyMailboxIsDisplayedWhenSwipeDownThenRefreshIsTriggered() {
+ val mailboxListState = MailboxListState.Data.ViewMode(
+ currentMailLabel = MailLabel.System(MailLabelId.System.Inbox),
+ openItemEffect = Effect.empty(),
+ scrollToMailboxTop = Effect.empty(),
+ offlineEffect = Effect.empty(),
+ refreshErrorEffect = Effect.empty(),
+ refreshRequested = false,
+ swipingEnabled = false,
+ swipeActions = null,
+ searchState = MailboxSearchStateSampleData.NotSearching,
+ clearState = MailboxListState.Data.ClearState.Hidden,
+ autoDeleteBannerState = MailboxListState.Data.AutoDeleteBannerState.Hidden
+ )
+ val mailboxState = MailboxStateSampleData.Loading.copy(mailboxListState = mailboxListState)
+ val robot = setupScreen(state = mailboxState)
+
+ // TODO
+ }
+
+ @Test
+ fun givenDataIsLoadedWhenCurrentLabelChangesThenScrollToTop() {
+ val items = (1..100).map { index ->
+ MailboxItemUiModelTestData.buildMailboxUiModelItem(
+ id = index.toString(),
+ type = MailboxItemType.Message
+ )
+ }
+ val itemsFlow = flowOf(PagingData.from(items))
+ val states = nonEmptyListOf(
+ MailLabelId.System.Trash to false,
+ MailLabelId.System.AllMail to true,
+ MailLabelId.System.Trash to true
+ ).map { (systemLabel, shouldScrollToTop) ->
+ val scrollToTopEffect: Effect =
+ if (shouldScrollToTop) Effect.of(systemLabel) else Effect.empty()
+ MailboxState(
+ mailboxListState = MailboxListState.Data.ViewMode(
+ currentMailLabel = MailLabel.System(systemLabel),
+ openItemEffect = Effect.empty(),
+ scrollToMailboxTop = scrollToTopEffect,
+ offlineEffect = Effect.empty(),
+ refreshErrorEffect = Effect.empty(),
+ refreshRequested = false,
+ swipingEnabled = false,
+ swipeActions = null,
+ searchState = MailboxSearchStateSampleData.NotSearching,
+ clearState = MailboxListState.Data.ClearState.Hidden,
+ autoDeleteBannerState = MailboxListState.Data.AutoDeleteBannerState.Hidden
+ ),
+ topAppBarState = MailboxTopAppBarState.Data.DefaultMode(
+ currentLabelName = MailLabel.System(systemLabel).text()
+ ),
+ upgradeStorageState = UpgradeStorageState(notificationDotVisible = false),
+ unreadFilterState = UnreadFilterState.Loading,
+ bottomAppBarState = BottomBarState.Data.Hidden(emptyList().toImmutableList()),
+ actionResult = Effect.empty(),
+ deleteDialogState = DeleteDialogState.Hidden,
+ deleteAllDialogState = DeleteDialogState.Hidden,
+ storageLimitState = StorageLimitState.HasEnoughSpace,
+ bottomSheetState = null,
+ error = Effect.empty(),
+ showRatingBooster = Effect.empty(),
+ autoDeleteSettingState = AutoDeleteSettingState.Loading
+ )
+ }
+
+ val stateManager = StateManager.of(states)
+ val robot = setupManagedState {
+ ManagedState(stateManager = stateManager) { mailboxState ->
+ MailboxScreen(
+ mailboxState = mailboxState,
+ mailboxListItems = itemsFlow.collectAsLazyPagingItems(),
+ actions = MailboxScreen.Actions.Empty
+ )
+ }
+ }
+
+ robot.listSection {
+ verify { listItemsAreShown(topMailboxItem) }
+ scrollToItemAtIndex(99)
+
+ stateManager.emitNext()
+
+ verify { listItemsAreShown(topMailboxItem) }
+ scrollToItemAtIndex(99)
+
+ stateManager.emitNext()
+
+ verify { listItemsAreShown(topMailboxItem) }
+ }
+ }
+
+ private fun setupManagedState(content: @Composable () -> Unit): MailboxRobot = mailboxRobot {
+ this@MailboxScreenTest.composeTestRule.setContent(content)
+ }
+
+ private fun setupScreen(
+ state: MailboxState = MailboxStateSampleData.Loading,
+ items: List = emptyList()
+ ): MailboxRobot = mailboxRobot {
+ this@MailboxScreenTest.composeTestRule.setContent {
+ val mailboxItems = flowOf(PagingData.from(items)).collectAsLazyPagingItems()
+
+ MailboxScreen(
+ mailboxState = state,
+ mailboxListItems = mailboxItems,
+ actions = MailboxScreen.Actions.Empty
+ )
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxTopAppBarTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxTopAppBarTest.kt
new file mode 100644
index 0000000000..4d1698c365
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxTopAppBarTest.kt
@@ -0,0 +1,283 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.mailbox
+
+import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
+import androidx.compose.ui.test.assertHasClickAction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsFocused
+import androidx.compose.ui.test.assertIsNotFocused
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import ch.protonmail.android.R
+import ch.protonmail.android.maillabel.domain.model.MailLabel
+import ch.protonmail.android.maillabel.domain.model.MailLabelId
+import ch.protonmail.android.maillabel.presentation.text
+import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxTopAppBar
+import ch.protonmail.android.mailmailbox.presentation.mailbox.model.MailboxTopAppBarState.Data
+import ch.protonmail.android.mailmailbox.presentation.mailbox.model.UpgradeStorageState
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.uitest.util.InstrumentationHolder
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.theme.ProtonTheme
+import org.junit.Test
+
+@Suppress("SameParameterValue") // We want test parameters to be explicit
+@RegressionTest
+@HiltAndroidTest
+internal class MailboxTopAppBarTest : HiltInstrumentedTest() {
+
+ private val context = InstrumentationHolder.instrumentation.targetContext
+
+ @Test
+ fun hamburgerIconIsShownInDefaultMode() {
+ setupScreenWithDefaultMode(MAIL_LABEL_INBOX)
+
+ composeTestRule
+ .onHamburgerIconButton()
+ .assertIsDisplayed()
+ .assertHasClickAction()
+ }
+
+ @Test
+ fun labelNameIsShownInDefaultMode() {
+ setupScreenWithDefaultMode(MAIL_LABEL_INBOX)
+
+ composeTestRule
+ .onNodeWithText(MAIL_LABEL_INBOX_TEXT)
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun actionsAreShownInDefaultMode() {
+ setupScreenWithDefaultMode(MAIL_LABEL_INBOX)
+
+ composeTestRule
+ .onSearchIconButton()
+ .assertIsDisplayed()
+ .assertHasClickAction()
+
+ composeTestRule
+ .onComposeIconButton()
+ .assertIsDisplayed()
+ .assertHasClickAction()
+ }
+
+ @Test
+ fun backIconIsShownInSelectionMode() {
+ setupScreenWithSelectionMode(MAIL_LABEL_INBOX, selectedCount = SELECTED_COUNT_TEN)
+
+ composeTestRule
+ .onExitSelectionModeIconButton()
+ .assertIsDisplayed()
+ .assertHasClickAction()
+ }
+
+ @Test
+ fun correctCountIsShownInSelectionMode() {
+ setupScreenWithSelectionMode(MAIL_LABEL_INBOX, selectedCount = SELECTED_COUNT_TEN)
+
+ composeTestRule
+ .onNodeWithText(
+ context.resources.getQuantityString(
+ R.plurals.mailbox_toolbar_selected_count,
+ SELECTED_COUNT_TEN,
+ SELECTED_COUNT_TEN
+ )
+ )
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun actionsAreHiddenInSelectionMode() {
+ setupScreenWithSelectionMode(MAIL_LABEL_INBOX, selectedCount = SELECTED_COUNT_TEN)
+
+ composeTestRule
+ .onSearchIconButton()
+ .assertDoesNotExist()
+
+ composeTestRule
+ .onComposeIconButton()
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun exitSearchModeButtonShownInSearchMode() {
+ setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "query")
+
+ composeTestRule
+ .onExitSearchIconButton()
+ .assertIsDisplayed()
+ .assertHasClickAction()
+
+ }
+
+ @Test
+ fun clearSearchQueryButtonShownInSearchModeWhenQueryEntered() {
+ setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "query")
+
+ composeTestRule
+ .onClearSearchQueryIconButton()
+ .assertIsDisplayed()
+ .assertHasClickAction()
+ }
+
+ @Test
+ fun clearSearchQueryButtonHiddenInSearchModeWhenQueryIsEmpty() {
+ setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "")
+
+ composeTestRule
+ .onClearSearchQueryIconButton()
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun searchTextFieldShouldHaveFocusWhenSearchModeStarted() {
+ setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "")
+
+ composeTestRule
+ .onNodeWithText("")
+ .assertIsDisplayed()
+ .assertIsFocused()
+ }
+
+ @Test
+ fun searchTextFieldShouldNotHaveFocusWhenSearchModeStartedWithExistingQuery() {
+ setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "search query")
+
+ composeTestRule
+ .onNodeWithText("search query")
+ .assertIsDisplayed()
+ .assertIsNotFocused()
+ }
+
+ @Test
+ fun clearSearchQueryButtonClearsSearchTextField() {
+ // Given
+ setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "non-empty query")
+ composeTestRule
+ .onNodeWithText("non-empty query")
+ .assertIsDisplayed()
+
+ // When
+ composeTestRule
+ .onClearSearchQueryIconButton()
+ .performClick()
+
+ // Then
+ composeTestRule
+ .onNodeWithText("")
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun clearSearchQueryButtonBecomeVisibleAfterEnteringQuery() {
+ // Given
+ setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "")
+ composeTestRule
+ .onClearSearchQueryIconButton()
+ .assertDoesNotExist()
+
+ // When
+ composeTestRule
+ .onNodeWithText("")
+ .performTextInput("some query")
+
+ // Then
+ composeTestRule
+ .onClearSearchQueryIconButton()
+ .assertIsDisplayed()
+ }
+
+ private fun setupScreenWithState(state: Data) {
+ composeTestRule.setContent {
+ ProtonTheme {
+ MailboxTopAppBar(
+ state = state,
+ upgradeStorageState = UpgradeStorageState(notificationDotVisible = false),
+ actions = MailboxTopAppBar.Actions(
+ onOpenMenu = {},
+ onExitSelectionMode = {},
+ onExitSearchMode = {},
+ onTitleClick = {},
+ onEnterSearchMode = {},
+ onSearch = {},
+ onOpenComposer = {},
+ onNavigateToStandaloneUpselling = {},
+ onOpenUpsellingPage = {},
+ onCloseUpsellingPage = {}
+ )
+ )
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ }
+
+ private fun setupScreenWithDefaultMode(currentMailLabel: MailLabel) {
+ val state = Data.DefaultMode(currentLabelName = currentMailLabel.text())
+ setupScreenWithState(state)
+ }
+
+ private fun setupScreenWithSelectionMode(currentMailLabel: MailLabel, selectedCount: Int) {
+ val state = Data.SelectionMode(
+ currentLabelName = currentMailLabel.text(),
+ selectedCount = selectedCount
+ )
+ setupScreenWithState(state)
+ }
+
+ private fun setupScreenWithSearchMode(currentMailLabel: MailLabel, searchQuery: String) {
+ val state = Data.SearchMode(
+ currentLabelName = currentMailLabel.text(),
+ searchQuery = searchQuery
+ )
+ setupScreenWithState(state)
+ }
+
+ private fun SemanticsNodeInteractionsProvider.onHamburgerIconButton() =
+ onNodeWithContentDescription(context.getString(R.string.mailbox_toolbar_menu_button_content_description))
+
+ private fun SemanticsNodeInteractionsProvider.onExitSelectionModeIconButton() = onNodeWithContentDescription(
+ context.getString(R.string.mailbox_toolbar_exit_selection_mode_button_content_description)
+ )
+
+ private fun SemanticsNodeInteractionsProvider.onSearchIconButton() =
+ onNodeWithContentDescription(context.getString(R.string.mailbox_toolbar_search_button_content_description))
+
+ private fun SemanticsNodeInteractionsProvider.onComposeIconButton() =
+ onNodeWithContentDescription(context.getString(R.string.mailbox_toolbar_compose_button_content_description))
+
+ private fun SemanticsNodeInteractionsProvider.onExitSearchIconButton() =
+ onNodeWithContentDescription(context.getString(R.string.mailbox_toolbar_exit_search_mode_content_description))
+
+ private fun SemanticsNodeInteractionsProvider.onClearSearchQueryIconButton() = onNodeWithContentDescription(
+ context.getString(R.string.mailbox_toolbar_searchview_clear_search_query_content_description)
+ )
+
+ private companion object TestData {
+
+ val MAIL_LABEL_INBOX = MailLabel.System(MailLabelId.System.Inbox)
+ const val MAIL_LABEL_INBOX_TEXT = "Inbox"
+ const val SELECTED_COUNT_TEN = 10
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxUnreadFiltersTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxUnreadFiltersTest.kt
new file mode 100644
index 0000000000..16b71f1c69
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxUnreadFiltersTest.kt
@@ -0,0 +1,92 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.mailbox
+
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailmailbox.presentation.mailbox.UnreadItemsFilter
+import ch.protonmail.android.mailmailbox.presentation.mailbox.UnreadItemsFilterTestTags
+import ch.protonmail.android.mailmailbox.presentation.mailbox.model.UnreadFilterState
+import ch.protonmail.android.test.annotations.suite.SmokeTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.theme.ProtonTheme
+import org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+internal class MailboxUnreadFiltersTest : HiltInstrumentedTest() {
+
+ private val unreadFilterNode by lazy {
+ composeTestRule
+ .onNodeWithTag(UnreadItemsFilterTestTags.UnreadFilterChip)
+ }
+
+ @Test
+ fun unreadFilterItemDisplaysZeroCounterWhenZero() {
+ // When
+ setupCountersItem(CounterZeroValue)
+
+ // Then
+ unreadFilterNode.assertTextEquals(CounterZeroValueText)
+ }
+
+ @Test
+ fun unreadFilterItemDisplaysCounterValueWhenBelowThreshold() {
+ // When
+ setupCountersItem(CounterValue)
+
+ // Then
+ unreadFilterNode.assertTextEquals(CounterValueText)
+ }
+
+ @Test
+ fun unreadFilterItemDisplaysCappedCounterValueWhenAboveThreshold() {
+ // When
+ setupCountersItem(CounterValueAboveThreshold)
+
+ // Then
+ unreadFilterNode.assertTextEquals(CounterValueTextCapped)
+ }
+
+ private fun setupCountersItem(count: Int) {
+ composeTestRule.setContent {
+ ProtonTheme {
+ UnreadItemsFilter(
+ state = UnreadFilterState.Data(
+ numUnread = count,
+ isFilterEnabled = false
+ ),
+ onFilterDisabled = {},
+ onFilterEnabled = {}
+ )
+ }
+ }
+ }
+
+ private companion object {
+
+ const val CounterZeroValue = 0
+ const val CounterZeroValueText = "$CounterZeroValue unread"
+ const val CounterValue = 10
+ const val CounterValueText = "$CounterValue unread"
+ const val CounterValueAboveThreshold = 10_000
+ const val CounterValueTextCapped = "9999+ unread"
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/account/AccountSettingsScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/account/AccountSettingsScreenTest.kt
new file mode 100644
index 0000000000..cd844c4572
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/account/AccountSettingsScreenTest.kt
@@ -0,0 +1,155 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.settings.account
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextContains
+import androidx.compose.ui.test.onChild
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performScrollToNode
+import ch.protonmail.android.mailsettings.presentation.R.string
+import ch.protonmail.android.mailsettings.presentation.accountsettings.AccountSettingScreen
+import ch.protonmail.android.mailsettings.presentation.accountsettings.AccountSettingsState.Data
+import ch.protonmail.android.mailsettings.presentation.accountsettings.AutoDeleteSettingsState
+import ch.protonmail.android.mailsettings.presentation.accountsettings.TEST_TAG_ACCOUNT_SETTINGS_LIST
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.uitest.util.assertions.assertTextContains
+import ch.protonmail.android.uitest.util.hasText
+import ch.protonmail.android.uitest.util.onNodeWithText
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.accountmanager.presentation.compose.R.string as CoreString
+import me.proton.core.compose.theme.ProtonTheme
+import org.junit.Before
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+internal class AccountSettingsScreenTest : HiltInstrumentedTest() {
+
+ private val settingsState = Data(
+ recoveryEmail = "recovery-email@protonmail.com",
+ mailboxSize = 20_000,
+ mailboxUsedSpace = 15_000,
+ defaultEmail = "contact@protonmail.ch",
+ isConversationMode = true,
+ registeredSecurityKeys = emptyList(),
+ securityKeysVisible = true,
+ autoDeleteSettingsState = AutoDeleteSettingsState(isSettingVisible = true)
+ )
+
+ @Before
+ fun setUp() {
+ composeTestRule.setContent {
+ ProtonTheme {
+ AccountSettingScreen(
+ state = settingsState,
+ actions = AccountSettingScreen.Actions(
+ onBackClick = {},
+ onPasswordManagementClick = {},
+ onRecoveryEmailClick = {},
+ onSecurityKeysClick = {},
+ onConversationModeClick = {},
+ onDefaultEmailAddressClick = {},
+ onDisplayNameClick = {},
+ onPrivacyClick = {},
+ onLabelsClick = {},
+ onFoldersClick = {},
+ onAutoDeleteClick = {}
+ )
+ )
+ }
+ }
+ }
+
+ @Test
+ fun testAccountSettingsScreenContainsAllExpectedSections() {
+ composeTestRule.onNodeWithText(string.mail_settings_account).assertIsDisplayed()
+ composeTestRule.onNodeWithText(string.mail_settings_addresses).assertIsDisplayed()
+ composeTestRule.onNodeWithText(string.mail_settings_mailbox).assertIsDisplayed()
+ }
+
+ @Test
+ fun testAccountSettingsScreenDisplayStateCorrectly() {
+ composeTestRule
+ .onNodeWithText(string.mail_settings_recovery_email)
+ .assertTextContains("recovery-email@protonmail.com")
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_password_management)
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_recovery_email)
+ .assertTextContains("recovery-email@protonmail.com")
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(CoreString.account_settings_list_item_security_keys_header)
+ .assertTextContains("Not set")
+ .assertIsDisplayed()
+
+ // Assert values individually as android's `Formatter.formatShortFileSize` method
+ // adds many non-printable BiDi chars when executing on some virtual devices
+ // so checking for "1 kB / 2 kB" would not find a match
+ composeTestRule
+ .onNodeWithText(string.mail_settings_mailbox_size)
+ .assertTextContains(value = "15", substring = true)
+ .assertTextContains(value = "20", substring = true)
+ .assertTextContains(value = "kB", substring = true, ignoreCase = true)
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_conversation_mode)
+ .assertTextContains(string.mail_settings_enabled)
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_auto_delete)
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_default_email_address)
+ .assertTextContains("contact@protonmail.ch")
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_display_name_and_signature)
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ACCOUNT_SETTINGS_LIST)
+ .onChild()
+ .performScrollToNode(hasText(string.mail_settings_privacy))
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ACCOUNT_SETTINGS_LIST)
+ .onChild()
+ .performScrollToNode(hasText(string.mail_settings_labels))
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ACCOUNT_SETTINGS_LIST)
+ .onChild()
+ .performScrollToNode(hasText(string.mail_settings_folders))
+ .assertIsDisplayed()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/account/conversationmode/ConversationModeSettingScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/account/conversationmode/ConversationModeSettingScreenTest.kt
new file mode 100644
index 0000000000..71912ec737
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/account/conversationmode/ConversationModeSettingScreenTest.kt
@@ -0,0 +1,81 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.settings.account.conversationmode
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsOff
+import androidx.compose.ui.test.assertIsOn
+import androidx.compose.ui.test.isToggleable
+import ch.protonmail.android.mailsettings.presentation.accountsettings.conversationmode.ConversationModeSettingScreen
+import ch.protonmail.android.mailsettings.presentation.accountsettings.conversationmode.ConversationModeSettingState.Data
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.theme.ProtonTheme
+import org.junit.Test
+
+@RegressionTest
+@HiltAndroidTest
+internal class ConversationModeSettingScreenTest : HiltInstrumentedTest() {
+
+ @Test
+ fun testConversationModeToggleIsOnWhenStateIsTrue() {
+ setupScreenWithState(Data(true))
+
+ composeTestRule
+ .onNode(isToggleable())
+ .assertIsDisplayed()
+ .assertIsEnabled()
+ .assertIsOn()
+ }
+
+ @Test
+ fun testConversationModeToggleIsOffWhenStateIsFalse() {
+ setupScreenWithState(Data(false))
+
+ composeTestRule
+ .onNode(isToggleable())
+ .assertIsDisplayed()
+ .assertIsEnabled()
+ .assertIsOff()
+ }
+
+ @Test
+ fun testConversationModeToggleIsOffWhenStateIsInvalid() {
+ setupScreenWithState(Data(null))
+
+ composeTestRule
+ .onNode(isToggleable())
+ .assertIsDisplayed()
+ .assertIsOff()
+ }
+
+ private fun setupScreenWithState(state: Data) {
+ composeTestRule.setContent {
+ ProtonTheme {
+ ConversationModeSettingScreen(
+ onBackClick = { },
+ onConversationModeToggled = { },
+ state = state
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/SettingsScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/SettingsScreenTest.kt
new file mode 100644
index 0000000000..fca906127c
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/SettingsScreenTest.kt
@@ -0,0 +1,169 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.settings.appsettings
+
+import androidx.compose.ui.test.assertHasNoClickAction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextContains
+import androidx.compose.ui.test.onChild
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performScrollToNode
+import ch.protonmail.android.mailbugreport.domain.LogsExportFeatureSetting
+import ch.protonmail.android.mailcommon.domain.AppInformation
+import ch.protonmail.android.mailsettings.domain.model.AppSettings
+import ch.protonmail.android.mailsettings.domain.model.LocalStorageUsageInformation
+import ch.protonmail.android.mailsettings.presentation.R.string
+import ch.protonmail.android.mailsettings.presentation.settings.AccountInfo
+import ch.protonmail.android.mailsettings.presentation.settings.MainSettingsScreen
+import ch.protonmail.android.mailsettings.presentation.settings.SettingsScreenTestTags
+import ch.protonmail.android.mailsettings.presentation.settings.SettingsState.Data
+import ch.protonmail.android.test.annotations.suite.SmokeTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.uitest.util.assertions.assertTextContains
+import ch.protonmail.android.uitest.util.hasText
+import ch.protonmail.android.uitest.util.onNodeWithText
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.theme.ProtonTheme
+import org.junit.Before
+import org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+internal class SettingsScreenTest : HiltInstrumentedTest() {
+
+ private val settingsState = Data(
+ AccountInfo("ProtonTest", "user-test@proton.ch"),
+ AppSettings(
+ hasAutoLock = false,
+ hasAlternativeRouting = true,
+ customAppLanguage = null,
+ hasCombinedContacts = true
+ ),
+ AppInformation(appVersionName = "6.0.0-alpha-adf8373a", appVersionCode = 9026),
+ LocalStorageUsageInformation(123L)
+ )
+
+ @Before
+ fun setUp() {
+ composeTestRule.setContent {
+ ProtonTheme {
+ MainSettingsScreen(
+ state = settingsState,
+ actions = MainSettingsScreen.Actions(
+ onAccountClick = {},
+ onThemeClick = {},
+ onPushNotificationsClick = {},
+ onAutoLockClick = {},
+ onAlternativeRoutingClick = {},
+ onAppLanguageClick = {},
+ onCombinedContactsClick = {},
+ onSwipeActionsClick = {},
+ onClearCacheClick = {},
+ onBackClick = {},
+ onExportLogsClick = {},
+ onCustomizeToolbarClick = {},
+ onSignOut = {}
+ ),
+ LogsExportFeatureSetting(enabled = false, internalEnabled = false)
+ )
+ }
+ }
+ }
+
+ @Test
+ fun testSettingsScreenContainsAllExpectedSections() {
+ composeTestRule.onNodeWithText(string.mail_settings_account_settings).assertIsDisplayed()
+ composeTestRule.onNodeWithText(string.mail_settings_app_settings).assertIsDisplayed()
+ composeTestRule
+ .onNodeWithTag(SettingsScreenTestTags.SettingsList)
+ .onChild()
+ .performScrollToNode(hasText(string.mail_settings_app_information))
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun testSettingsScreenDisplayStateCorrectly() {
+ composeTestRule
+ .onNodeWithText("ProtonTest")
+ .assertTextContains("user-test@proton.ch")
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme)
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_push_notifications)
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_auto_lock)
+ .assertTextContains(string.mail_settings_disabled)
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_alternative_routing)
+ .assertTextContains(string.mail_settings_allowed)
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_app_language)
+ .assertTextContains(string.mail_settings_auto_detect)
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_combined_contacts)
+ .assertTextContains(string.mail_settings_enabled)
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithTag(SettingsScreenTestTags.SettingsList)
+ .onChild()
+ .performScrollToNode(hasText(string.mail_settings_local_cache))
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithTag(SettingsScreenTestTags.SettingsList)
+ .onChild()
+ .performScrollToNode(hasText(string.mail_settings_customize_toolbar))
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithTag(SettingsScreenTestTags.SettingsList)
+ .onChild()
+ .performScrollToNode(hasText(string.mail_settings_swipe_actions))
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithTag(SettingsScreenTestTags.SettingsList)
+ .onChild()
+ .performScrollToNode(hasText(string.mail_settings_app_version))
+
+ composeTestRule
+ .onNodeWithText("6.0.0-alpha-adf8373a (9026)")
+ .assertHasNoClickAction()
+ .assertIsDisplayed()
+
+ composeTestRule
+ .onNodeWithTag(SettingsScreenTestTags.SettingsList)
+ .onChild()
+ .performScrollToNode(hasText(string.mail_settings_app_version))
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/alternativerouting/AlternativeRoutingSettingScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/alternativerouting/AlternativeRoutingSettingScreenTest.kt
new file mode 100644
index 0000000000..2def755f49
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/alternativerouting/AlternativeRoutingSettingScreenTest.kt
@@ -0,0 +1,152 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.settings.appsettings.alternativerouting
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsOff
+import androidx.compose.ui.test.assertIsOn
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailcommon.presentation.Effect
+import ch.protonmail.android.mailsettings.presentation.settings.alternativerouting.AlternativeRoutingSettingScreen
+import ch.protonmail.android.mailsettings.presentation.settings.alternativerouting.AlternativeRoutingSettingState
+import ch.protonmail.android.mailsettings.presentation.settings.alternativerouting.TEST_TAG_ALTERNATIVE_ROUTING_SNACKBAR
+import ch.protonmail.android.mailsettings.presentation.settings.alternativerouting.TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.component.PROTON_PROGRESS_TEST_TAG
+import me.proton.core.compose.component.ProtonCenteredProgress
+import me.proton.core.compose.theme.ProtonTheme
+import org.junit.Test
+import kotlin.test.assertEquals
+
+@RegressionTest
+@HiltAndroidTest
+internal class AlternativeRoutingSettingScreenTest : HiltInstrumentedTest() {
+
+ @Test
+ fun testProgressIsShownWhenStateIsLoading() {
+ setupScreenWithState(AlternativeRoutingSettingState.Loading)
+
+ composeTestRule
+ .onNodeWithTag(PROTON_PROGRESS_TEST_TAG)
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun testSwitchIsCheckedIfAlternativeRoutingSettingIsEnabled() {
+ setupScreenWithState(
+ AlternativeRoutingSettingState.Data(isEnabled = true, alternativeRoutingSettingErrorEffect = Effect.empty())
+ )
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM)
+ .assertIsOn()
+ }
+
+ @Test
+ fun testSwitchIsNotCheckedIfAlternativeRoutingSettingIsNotEnabled() {
+ setupScreenWithState(
+ AlternativeRoutingSettingState.Data(
+ isEnabled = false,
+ alternativeRoutingSettingErrorEffect = Effect.empty()
+ )
+ )
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM)
+ .assertIsOff()
+ }
+
+ @Test
+ fun testCallbackIsInvokedWhenSwitchIsToggled() {
+ var isEnabled = false
+ setupScreenWithState(
+ state = AlternativeRoutingSettingState.Data(
+ isEnabled = false,
+ alternativeRoutingSettingErrorEffect = Effect.empty()
+ ),
+ onToggle = { isEnabled = !isEnabled }
+ )
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM)
+ .performClick()
+
+ assertEquals(true, isEnabled)
+ }
+
+ @Test
+ fun testErrorSnackbarIsShownWhenStateContainsThrowableEffect() {
+ setupScreenWithState(
+ AlternativeRoutingSettingState.Data(
+ isEnabled = false,
+ alternativeRoutingSettingErrorEffect = Effect.of(Unit)
+ )
+ )
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_SNACKBAR)
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun testSwitchIsOffAndSnackbarIsShownWhenSwitchStateIsNull() {
+ setupScreenWithState(
+ AlternativeRoutingSettingState.Data(
+ isEnabled = null,
+ alternativeRoutingSettingErrorEffect = Effect.of(Unit)
+ )
+ )
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM)
+ .assertIsDisplayed()
+ .assertIsOff()
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_SNACKBAR)
+ .assertIsDisplayed()
+ }
+
+ private fun setupScreenWithState(
+ state: AlternativeRoutingSettingState,
+ onBackClick: () -> Unit = {},
+ onToggle: (Boolean) -> Unit = {}
+ ) {
+ composeTestRule.setContent {
+ ProtonTheme {
+ when (state) {
+ is AlternativeRoutingSettingState.Data -> {
+ AlternativeRoutingSettingScreen(
+ onBackClick = onBackClick,
+ onToggle = onToggle,
+ state = state
+ )
+ }
+ is AlternativeRoutingSettingState.Loading -> {
+ ProtonCenteredProgress(Modifier.fillMaxWidth())
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/combinedcontacts/CombinedContactsSettingScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/combinedcontacts/CombinedContactsSettingScreenTest.kt
new file mode 100644
index 0000000000..6e5f1542dd
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/combinedcontacts/CombinedContactsSettingScreenTest.kt
@@ -0,0 +1,129 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.settings.appsettings.combinedcontacts
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsOff
+import androidx.compose.ui.test.assertIsOn
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailcommon.presentation.Effect
+import ch.protonmail.android.mailsettings.presentation.settings.combinedcontacts.CombinedContactsSettingScreen
+import ch.protonmail.android.mailsettings.presentation.settings.combinedcontacts.CombinedContactsSettingState
+import ch.protonmail.android.mailsettings.presentation.settings.combinedcontacts.TEST_TAG_COMBINED_CONTACTS_SNACKBAR
+import ch.protonmail.android.mailsettings.presentation.settings.combinedcontacts.TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.theme.ProtonTheme
+import org.junit.Test
+import kotlin.test.assertEquals
+
+@RegressionTest
+@HiltAndroidTest
+internal class CombinedContactsSettingScreenTest : HiltInstrumentedTest() {
+
+ @Test
+ fun testSwitchIsCheckedIfCombinedContactsSettingIsEnabled() {
+ setupScreenWithState(
+ CombinedContactsSettingState.Data(isEnabled = true, combinedContactsSettingErrorEffect = Effect.empty())
+ )
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM)
+ .assertIsOn()
+ }
+
+ @Test
+ fun testSwitchIsNotCheckedIfCombinedContactsSettingIsNotEnabled() {
+ setupScreenWithState(
+ CombinedContactsSettingState.Data(isEnabled = false, combinedContactsSettingErrorEffect = Effect.empty())
+ )
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM)
+ .assertIsOff()
+ }
+
+ @Test
+ fun testCallbackIsInvokedWhenSwitchIsToggled() {
+ var isEnabled = false
+ setupScreenWithState(
+ state = CombinedContactsSettingState.Data(
+ isEnabled = false,
+ combinedContactsSettingErrorEffect = Effect.empty()
+ ),
+ onToggle = { isEnabled = !isEnabled }
+ )
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM)
+ .performClick()
+
+ assertEquals(true, isEnabled)
+ }
+
+ @Test
+ fun testErrorSnackbarIsShownWhenStateContainsThrowableEffect() {
+ setupScreenWithState(
+ CombinedContactsSettingState.Data(
+ isEnabled = false,
+ combinedContactsSettingErrorEffect = Effect.of(Unit)
+ )
+ )
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_SNACKBAR)
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun testSwitchIsOffAndSnackbarIsShownWhenSwitchStateIsNull() {
+ setupScreenWithState(
+ CombinedContactsSettingState.Data(
+ isEnabled = null,
+ combinedContactsSettingErrorEffect = Effect.of(Unit)
+ )
+ )
+
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM)
+ .assertIsDisplayed()
+ .assertIsOff()
+ composeTestRule
+ .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_SNACKBAR)
+ .assertIsDisplayed()
+ }
+
+ private fun setupScreenWithState(
+ state: CombinedContactsSettingState.Data,
+ onBackClick: () -> Unit = {},
+ onToggle: (Boolean) -> Unit = {}
+ ) {
+ composeTestRule.setContent {
+ ProtonTheme {
+ CombinedContactsSettingScreen(
+ onBackClick = onBackClick,
+ onToggle = onToggle,
+ state = state
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/swipeactions/EditSwipeActionPreferenceScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/swipeactions/EditSwipeActionPreferenceScreenTest.kt
new file mode 100644
index 0000000000..4c3027ae88
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/swipeactions/EditSwipeActionPreferenceScreenTest.kt
@@ -0,0 +1,130 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.settings.appsettings.swipeactions
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotSelected
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.mailsettings.domain.model.SwipeActionDirection
+import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.EditSwipeActionPreferenceScreen
+import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.EditSwipeActionPreferenceState
+import ch.protonmail.android.mailsettings.presentation.testdata.SwipeActionsTestData
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.uitest.util.onNodeWithText
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.component.PROTON_PROGRESS_TEST_TAG
+import me.proton.core.compose.theme.ProtonTheme
+import me.proton.core.mailsettings.domain.entity.SwipeAction
+import org.junit.Test
+import ch.protonmail.android.mailsettings.presentation.R.string as settingsString
+
+@RegressionTest
+@HiltAndroidTest
+internal class EditSwipeActionPreferenceScreenTest : HiltInstrumentedTest() {
+
+ @Test
+ fun whenRightSwipeIsSelectedCorrectTitleIsShown() {
+ // when
+ setContentWithState(EditSwipeActionPreferenceState.Loading, direction = SwipeActionDirection.RIGHT)
+
+ // then
+ composeTestRule.onNodeWithText(settingsString.mail_settings_swipe_right_name)
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun whenLeftSwipeIsSelectedCorrectTitleIsShown() {
+ // when
+ setContentWithState(EditSwipeActionPreferenceState.Loading, direction = SwipeActionDirection.LEFT)
+
+ // then
+ composeTestRule.onNodeWithText(settingsString.mail_settings_swipe_left_name)
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun whileDataIsLoadingProgressIsShown() {
+ // when
+ setContentWithState(EditSwipeActionPreferenceState.Loading, direction = SwipeActionDirection.LEFT)
+
+ // then
+ composeTestRule.onNodeWithTag(PROTON_PROGRESS_TEST_TAG)
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun whenDataIsReadyCorrectItemIsSelected() {
+ // when
+ val items = SwipeActionsTestData.Edit.buildAllItems(selected = SwipeAction.Star)
+ setContentWithState(EditSwipeActionPreferenceState.Data(items), direction = SwipeActionDirection.LEFT)
+
+ // then
+ composeTestRule.onArchive()
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+
+ composeTestRule.onRead()
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+
+ composeTestRule.onSpam()
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+
+ composeTestRule.onStar()
+ .assertIsDisplayed()
+ .assertIsSelected()
+
+ composeTestRule.onTrash()
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+ }
+
+ private fun setContentWithState(state: EditSwipeActionPreferenceState, direction: SwipeActionDirection) {
+ composeTestRule.setContent {
+ ProtonTheme {
+ EditSwipeActionPreferenceScreen(
+ state = state,
+ direction = direction,
+ onBack = {},
+ onSwipeActionSelect = {}
+ )
+ }
+ }
+ }
+
+ private fun ComposeTestRule.onArchive(): SemanticsNodeInteraction =
+ onNodeWithText(settingsString.mail_settings_swipe_action_archive_description)
+
+ private fun ComposeTestRule.onRead(): SemanticsNodeInteraction =
+ onNodeWithText(settingsString.mail_settings_swipe_action_read_description)
+
+ private fun ComposeTestRule.onSpam(): SemanticsNodeInteraction =
+ onNodeWithText(settingsString.mail_settings_swipe_action_spam_description)
+
+ private fun ComposeTestRule.onStar(): SemanticsNodeInteraction =
+ onNodeWithText(settingsString.mail_settings_swipe_action_star_description)
+
+ private fun ComposeTestRule.onTrash(): SemanticsNodeInteraction =
+ onNodeWithText(settingsString.mail_settings_swipe_action_trash_description)
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/swipeactions/SwipeActionsPreferenceScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/swipeactions/SwipeActionsPreferenceScreenTest.kt
new file mode 100644
index 0000000000..d4f3064da7
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/swipeactions/SwipeActionsPreferenceScreenTest.kt
@@ -0,0 +1,108 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.settings.appsettings.swipeactions
+
+import androidx.compose.ui.test.assertAny
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onChildren
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onParent
+import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.SwipeActionPreferenceUiModel
+import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.SwipeActionsPreferenceScreen
+import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.SwipeActionsPreferenceState
+import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.SwipeActionsPreferenceState.Loading
+import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.SwipeActionsPreferenceUiModel
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.uitest.util.hasText
+import ch.protonmail.android.uitest.util.onNodeWithText
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.component.PROTON_PROGRESS_TEST_TAG
+import me.proton.core.compose.theme.ProtonTheme
+import kotlin.test.Test
+import ch.protonmail.android.mailsettings.presentation.R as SettingsR
+import me.proton.core.presentation.R as CoreR
+
+@RegressionTest
+@HiltAndroidTest
+internal class SwipeActionsPreferenceScreenTest : HiltInstrumentedTest() {
+
+ @Test
+ fun progressIsShownWhileDataLoading() {
+ setContentWithState(Loading)
+
+ composeTestRule
+ .onNodeWithTag(PROTON_PROGRESS_TEST_TAG)
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun correctActionsAreShown() {
+ setContentWithState(swipeActionsData)
+
+ composeTestRule
+ .onNodeWithText(SettingsR.string.mail_settings_swipe_right_name)
+ .onParent()
+ .onChildren()
+ .assertAny(hasText(swipeActionsData.model.right.titleRes))
+ .assertAny(hasText(swipeActionsData.model.right.descriptionRes))
+
+ composeTestRule
+ .onNodeWithText(SettingsR.string.mail_settings_swipe_left_name)
+ .onParent()
+ .onChildren()
+ .assertAny(hasText(swipeActionsData.model.left.titleRes))
+ .assertAny(hasText(swipeActionsData.model.left.descriptionRes))
+ }
+
+ private fun setContentWithState(state: SwipeActionsPreferenceState) {
+ composeTestRule.setContent {
+ ProtonTheme {
+ SwipeActionsPreferenceScreen(
+ state = state,
+ actions = SwipeActionsPreferenceScreen.Actions(
+ onBackClick = {},
+ onChangeSwipeRightClick = {},
+ onChangeSwipeLeftClick = {}
+ )
+ )
+ }
+ }
+ }
+
+ private companion object TestData {
+
+ private val swipeActionsData = SwipeActionsPreferenceState.Data(
+ SwipeActionsPreferenceUiModel(
+ left = SwipeActionPreferenceUiModel(
+ imageRes = CoreR.drawable.ic_proton_archive_box,
+ titleRes = SettingsR.string.mail_settings_swipe_action_archive_title,
+ descriptionRes = SettingsR.string.mail_settings_swipe_action_archive_description,
+ getColor = { ProtonTheme.colors.iconHint }
+ ),
+ right = SwipeActionPreferenceUiModel(
+ imageRes = CoreR.drawable.ic_proton_trash,
+ titleRes = SettingsR.string.mail_settings_swipe_action_trash_title,
+ descriptionRes = SettingsR.string.mail_settings_swipe_action_trash_description,
+ getColor = { ProtonTheme.colors.notificationError }
+ )
+ )
+ )
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/theme/ThemeSettingScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/theme/ThemeSettingScreenTest.kt
new file mode 100644
index 0000000000..2233fcefb3
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/theme/ThemeSettingScreenTest.kt
@@ -0,0 +1,175 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.settings.appsettings.theme
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotSelected
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.performClick
+import ch.protonmail.android.mailsettings.domain.model.Theme
+import ch.protonmail.android.mailsettings.domain.model.Theme.DARK
+import ch.protonmail.android.mailsettings.domain.model.Theme.LIGHT
+import ch.protonmail.android.mailsettings.domain.model.Theme.SYSTEM_DEFAULT
+import ch.protonmail.android.mailsettings.presentation.R.string
+import ch.protonmail.android.mailsettings.presentation.settings.theme.ThemeSettingsScreen
+import ch.protonmail.android.mailsettings.presentation.settings.theme.ThemeSettingsState.Data
+import ch.protonmail.android.mailsettings.presentation.settings.theme.ThemeUiModel
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.uitest.util.onNodeWithText
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.theme.ProtonTheme
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+@RegressionTest
+@HiltAndroidTest
+internal class ThemeSettingScreenTest : HiltInstrumentedTest() {
+
+ @Test
+ fun testOnlySystemDefaultIsSelectedWhenThemeIsSystemDefault() {
+ setupScreenWithSystemDefaultTheme()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_system_default)
+ .assertIsDisplayed()
+ .assertIsSelected()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme_light)
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme_dark)
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+ }
+
+ @Test
+ fun testLightIsSelectedWhenThemeIsLight() {
+ setupScreenWithLightTheme()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_system_default)
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme_light)
+ .assertIsDisplayed()
+ .assertIsSelected()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme_dark)
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+ }
+
+ @Test
+ fun testDarkIsSelectedWhenThemeIsDark() {
+ setupScreenWithDarkTheme()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_system_default)
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme_light)
+ .assertIsDisplayed()
+ .assertIsNotSelected()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme_dark)
+ .assertIsDisplayed()
+ .assertIsSelected()
+ }
+
+ @Test
+ fun testCallbackIsInvokedWithThemeIdWhenAThemeIsSelected() {
+ var selectedTheme: Theme? = null
+ setupScreenWithSystemDefaultTheme {
+ selectedTheme = it
+ }
+ assertNull(selectedTheme)
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_system_default)
+ .assertIsDisplayed()
+ .assertIsSelected()
+
+ composeTestRule
+ .onNodeWithText(string.mail_settings_theme_dark)
+ .performClick()
+
+ assertEquals(DARK, selectedTheme)
+ }
+
+ private fun setupScreenWithLightTheme() {
+ setupScreenWithState(
+ Data(
+ buildThemesList(isSystemDefault = false, isLight = true, isDark = false)
+ )
+ )
+ }
+
+ private fun setupScreenWithDarkTheme() {
+ setupScreenWithState(
+ Data(
+ buildThemesList(isSystemDefault = false, isLight = false, isDark = true)
+ )
+ )
+ }
+
+ private fun setupScreenWithSystemDefaultTheme(onThemeSelected: (Theme) -> Unit = {}) {
+ setupScreenWithState(
+ Data(
+ buildThemesList(isSystemDefault = true, isLight = false, isDark = false)
+ ),
+ onThemeSelected
+ )
+ }
+
+ private fun buildThemesList(
+ isSystemDefault: Boolean,
+ isLight: Boolean,
+ isDark: Boolean
+ ) = listOf(
+ ThemeUiModel(SYSTEM_DEFAULT, string.mail_settings_system_default, isSystemDefault),
+ ThemeUiModel(LIGHT, string.mail_settings_theme_light, isLight),
+ ThemeUiModel(DARK, string.mail_settings_theme_dark, isDark)
+ )
+
+ private fun setupScreenWithState(
+ state: Data,
+ onThemeSelected: (Theme) -> Unit = {}
+ ) {
+ composeTestRule.setContent {
+ ProtonTheme {
+ ThemeSettingsScreen(
+ onBackClick = { },
+ onThemeSelected = onThemeSelected,
+ state = state
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/sidebar/SidebarScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/sidebar/SidebarScreenTest.kt
new file mode 100644
index 0000000000..66ca2a85a8
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/sidebar/SidebarScreenTest.kt
@@ -0,0 +1,182 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.sidebar
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.onChild
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performScrollToNode
+import androidx.compose.ui.unit.dp
+import ch.protonmail.android.mailcommon.domain.AppInformation
+import ch.protonmail.android.mailcommon.presentation.model.TextUiModel
+import ch.protonmail.android.maillabel.domain.model.MailLabelId
+import ch.protonmail.android.maillabel.presentation.MailLabelUiModel
+import ch.protonmail.android.maillabel.presentation.MailLabelsUiModel
+import ch.protonmail.android.maillabel.presentation.R
+import ch.protonmail.android.mailsidebar.presentation.Sidebar
+import ch.protonmail.android.mailsidebar.presentation.SidebarMenuTestTags
+import ch.protonmail.android.mailsidebar.presentation.SidebarState
+import ch.protonmail.android.test.annotations.suite.RegressionTest
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.uitest.util.onNodeWithText
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.theme.ProtonTheme
+import me.proton.core.label.domain.entity.LabelId
+import org.junit.Test
+import ch.protonmail.android.maillabel.R as label
+import me.proton.core.presentation.compose.R as core
+
+private const val APP_VERSION_FOOTER = "Proton Mail 6.0.0-alpha+test"
+
+@RegressionTest
+@HiltAndroidTest
+internal class SidebarScreenTest : HiltInstrumentedTest() {
+
+ @Test
+ fun subscriptionIsShownWhenSidebarStateIsDisplaySubscription() {
+ setupScreenWithState(showSubscriptionSidebarState())
+
+ scrollToSidebarBottom()
+
+ composeTestRule
+ .onNodeWithText(core.string.presentation_menu_item_title_subscription)
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun subscriptionIsHiddenWhenSidebarStateIsHideSubscription() {
+ setupScreenWithState(hideSubscriptionSidebarState())
+
+ scrollToSidebarBottom()
+ composeTestRule
+ .onNodeWithText(core.string.presentation_menu_item_title_subscription)
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun labelsAreOnlyDisplayingTitleEmptyItemsAndAddItem() {
+ setupScreenWithState(emptyLabelsSidebarState())
+
+ listOf(
+ label.string.label_title_labels,
+ label.string.label_title_folders,
+ label.string.label_title_create_folder,
+ label.string.label_title_create_label
+ ).forEach {
+ composeTestRule
+ .onNodeWithText(it)
+ .assertIsDisplayed()
+ }
+ }
+
+ @Test
+ fun labelsAndFoldersAreDisplayed() {
+ setupScreenWithState(someLabelsSidebarState())
+
+ listOf(
+ "Folder1",
+ "Folder2",
+ "Folder3",
+ "Label1",
+ "Label2",
+ "Label3"
+ ).forEach {
+ composeTestRule
+ .onNodeWithText(it)
+ .assertIsDisplayed()
+ }
+ }
+
+ private fun scrollToSidebarBottom(): SemanticsNodeInteraction {
+ return composeTestRule
+ .onNodeWithTag(SidebarMenuTestTags.Root)
+ .onChild()
+ .performScrollToNode(hasText(APP_VERSION_FOOTER, true))
+ }
+
+ private fun showSubscriptionSidebarState() = buildSidebarState(isSubscriptionVisible = true)
+ private fun hideSubscriptionSidebarState() = buildSidebarState(isSubscriptionVisible = false)
+ private fun emptyLabelsSidebarState() = buildSidebarState(mailLabels = MailLabelsUiModel.Loading)
+ private fun someLabelsSidebarState() = buildSidebarState(
+ mailLabels = MailLabelsUiModel(
+ systems = emptyList(),
+ folders = listOf(
+ buildMailLabelFolderUiModel("Folder1"),
+ buildMailLabelFolderUiModel("Folder2"),
+ buildMailLabelFolderUiModel("Folder3")
+ ),
+ labels = listOf(
+ buildMailLabelLabelUiModel("Label1"),
+ buildMailLabelLabelUiModel("Label2"),
+ buildMailLabelLabelUiModel("Label3")
+ )
+ )
+ )
+
+ private fun buildMailLabelFolderUiModel(text: String) = MailLabelUiModel.Custom(
+ id = MailLabelId.Custom.Folder(LabelId(text)),
+ key = text,
+ text = TextUiModel.Text(text),
+ icon = R.drawable.ic_proton_folder_filled,
+ iconTint = Color(0),
+ isSelected = false,
+ count = 0,
+ isVisible = true,
+ isExpanded = false,
+ iconPaddingStart = 0.dp
+ )
+
+ private fun buildMailLabelLabelUiModel(text: String) = MailLabelUiModel.Custom(
+ id = MailLabelId.Custom.Label(LabelId(text)),
+ key = text,
+ text = TextUiModel.Text(text),
+ icon = R.drawable.ic_proton_circle_filled,
+ iconTint = Color(0),
+ isSelected = false,
+ count = 0,
+ isVisible = true,
+ isExpanded = false,
+ iconPaddingStart = 0.dp
+ )
+
+ private fun buildSidebarState(
+ isSubscriptionVisible: Boolean = true,
+ mailLabels: MailLabelsUiModel = MailLabelsUiModel.Loading
+ ) = SidebarState(
+ isSubscriptionVisible = isSubscriptionVisible,
+ hasPrimaryAccount = false,
+ appInformation = AppInformation(
+ appName = "Proton Mail",
+ appVersionName = "6.0.0-alpha+test"
+ ),
+ mailLabels = mailLabels
+ )
+
+ private fun setupScreenWithState(state: SidebarState) {
+ composeTestRule.setContent {
+ ProtonTheme {
+ Sidebar(viewState = state, actions = Sidebar.Actions.Empty)
+ }
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/sidebar/SidebarWithCounterItemTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/sidebar/SidebarWithCounterItemTest.kt
new file mode 100644
index 0000000000..27b7afbd81
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/sidebar/SidebarWithCounterItemTest.kt
@@ -0,0 +1,135 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.sidebar
+
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.onNodeWithTag
+import ch.protonmail.android.maillabel.presentation.MailLabelUiModel
+import ch.protonmail.android.maillabel.presentation.sidebar.SidebarItemWithCounterTestTags
+import ch.protonmail.android.maillabel.presentation.sidebar.sidebarLabelItems
+import ch.protonmail.android.maillabel.presentation.sidebar.sidebarSystemLabelItems
+import ch.protonmail.android.test.annotations.suite.SmokeTest
+import ch.protonmail.android.testdata.maillabel.MailLabelUiModelTestData
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.component.ProtonSidebarLazy
+import me.proton.core.compose.theme.ProtonTheme
+import org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+internal class SidebarWithCounterItemTest : HiltInstrumentedTest() {
+
+ private val countersNode by lazy {
+ composeTestRule.onNodeWithTag(SidebarItemWithCounterTestTags.Counter, useUnmergedTree = true)
+ }
+
+ @Test
+ fun sidebarSystemLabelCounterDisplaysValueWhenAvailable() {
+ // Given
+ val systemFolder = MailLabelUiModelTestData.spamFolder.copy(count = CounterValue)
+
+ // When
+ setupLabelItem(systemFolder)
+
+ // Then
+ countersNode.assertTextEquals(CounterValueText)
+ }
+
+ @Test
+ fun sidebarSystemLabelItemDisplaysNoCounterValueWhenNull() {
+ // Given
+ val systemFolder = MailLabelUiModelTestData.spamFolder.copy(count = null)
+
+ // When
+ setupLabelItem(systemFolder)
+
+ // Then
+ countersNode.assertDoesNotExist()
+ }
+
+ @Test
+ fun sidebarSystemLabelItemDisplaysCappedCounterValueWhenAboveThreshold() {
+ // Given
+ val systemFolder = MailLabelUiModelTestData.spamFolder.copy(count = CounterValueAboveThreshold)
+
+ // When
+ setupLabelItem(systemFolder)
+
+ // Then
+ countersNode.assertTextEquals(CounterValueTextCapped)
+ }
+
+ @Test
+ fun sidebarCustomLabelCounterDisplaysValueWhenAvailable() {
+ // Given
+ val customLabel = MailLabelUiModelTestData.customLabelList.first().copy(count = CounterValue)
+
+ // When
+ setupLabelItem(customLabel)
+
+ // Then
+ countersNode.assertTextEquals(CounterValueText)
+ }
+
+ @Test
+ fun sidebarCustomLabelItemDisplaysNoCounterValueWhenNull() {
+ // Given
+ val customLabel = MailLabelUiModelTestData.customLabelList.first().copy(count = null)
+
+ // When
+ setupLabelItem(customLabel)
+
+ // Then
+ countersNode.assertDoesNotExist()
+ }
+
+ @Test
+ fun sidebarCustomLabelItemDisplaysCappedCounterValueWhenAboveThreshold() {
+ // Given
+ val customLabel = MailLabelUiModelTestData.customLabelList.first().copy(count = CounterValueAboveThreshold)
+
+ // When
+ setupLabelItem(customLabel)
+
+ // Then
+ countersNode.assertTextEquals(CounterValueTextCapped)
+ }
+
+ private fun setupLabelItem(item: MailLabelUiModel) {
+ composeTestRule.setContent {
+ ProtonTheme {
+ ProtonSidebarLazy {
+ when (item) {
+ is MailLabelUiModel.Custom -> sidebarLabelItems(listOf(item)) {}
+ is MailLabelUiModel.System -> sidebarSystemLabelItems(listOf(item)) {}
+ }
+ }
+ }
+ }
+ }
+
+ private companion object {
+
+ const val CounterValue = 10
+ const val CounterValueText = CounterValue.toString()
+ const val CounterValueAboveThreshold = 10_000
+ const val CounterValueTextCapped = "9999+"
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/snackbar/DismissableSnackbarHostTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/snackbar/DismissableSnackbarHostTest.kt
new file mode 100644
index 0000000000..d444e07c48
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/snackbar/DismissableSnackbarHostTest.kt
@@ -0,0 +1,120 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.screen.snackbar
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.Scaffold
+import androidx.compose.material.SnackbarDuration
+import androidx.compose.material.rememberScaffoldState
+import androidx.compose.material.Text
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.getBoundsInRoot
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeLeft
+import androidx.compose.ui.test.swipeRight
+import ch.protonmail.android.test.annotations.suite.SmokeTest
+import ch.protonmail.android.uicomponents.snackbar.DismissableSnackbarHost
+import ch.protonmail.android.uitest.util.HiltInstrumentedTest
+import ch.protonmail.android.uitest.util.awaitDisplayed
+import ch.protonmail.android.uitest.util.awaitHidden
+import dagger.hilt.android.testing.HiltAndroidTest
+import me.proton.core.compose.component.ProtonSnackbarHostState
+import me.proton.core.compose.component.ProtonSnackbarType
+import me.proton.core.compose.theme.ProtonTheme
+import org.junit.Test
+
+@SmokeTest
+@HiltAndroidTest
+internal class DismissableSnackbarHostTest : HiltInstrumentedTest() {
+
+ private val snackbarHost = composeTestRule.onNodeWithTag(MainSnackbarTestTag)
+
+ @Test
+ fun snackbarCanBeDismissedRtl() {
+ // Given
+ prepareScreenWithSnackbarHost()
+ snackbarHost.awaitDisplayed()
+
+ // When
+ snackbarHost.performTouchInput {
+ // Override startX and endX otherwise it does not have enough space to perform the swipe action.
+ swipeLeft(
+ startX = snackbarHost.getBoundsInRoot().right.value,
+ endX = snackbarHost.getBoundsInRoot().left.value
+ )
+ }
+
+ // Then
+ snackbarHost.awaitHidden()
+ }
+
+ @Test
+ fun snackbarCanBeDismissedLtr() {
+ // Given
+ prepareScreenWithSnackbarHost()
+ snackbarHost.awaitDisplayed()
+
+ // When
+ snackbarHost.performTouchInput {
+ swipeRight()
+ }
+
+ // Then
+ snackbarHost.awaitHidden()
+ }
+
+ private fun prepareScreenWithSnackbarHost() {
+ composeTestRule.setContent {
+ ProtonTheme {
+ val protonSnackbarHostState = remember { ProtonSnackbarHostState() }
+
+ LaunchedEffect(Unit) {
+ protonSnackbarHostState.showSnackbar(
+ ProtonSnackbarType.NORM,
+ "Hello hello hello",
+ duration = SnackbarDuration.Indefinite
+ )
+ }
+
+ Scaffold(
+ scaffoldState = rememberScaffoldState(),
+ snackbarHost = {
+ DismissableSnackbarHost(
+ modifier = Modifier.testTag(MainSnackbarTestTag),
+ protonSnackbarHostState = protonSnackbarHostState
+ )
+ }
+ ) { paddingValues ->
+ Text(
+ modifier = Modifier.padding(paddingValues), text = "Text"
+ )
+ }
+ }
+ }
+ }
+
+ private companion object {
+
+ const val MainSnackbarTestTag = "MainSnackbar"
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/ActivityScenarioHolder.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/ActivityScenarioHolder.kt
new file mode 100644
index 0000000000..ac8126efae
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/ActivityScenarioHolder.kt
@@ -0,0 +1,43 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util
+
+import androidx.test.core.app.ActivityScenario
+import ch.protonmail.android.MainActivity
+
+internal object ActivityScenarioHolder {
+
+ private val _scenario: Lazy> = lazy {
+ ActivityScenario.launch(MainActivity::class.java)
+ }
+
+ val scenario: ActivityScenario
+ get() {
+ check(_scenario.isInitialized()) {
+ "ActivityScenario not initialized. Make sure the current test has explicitly launched the app."
+ }
+
+ return _scenario.value
+ }
+
+ fun initialize() {
+ // Under the hood it does nothing, just calls the backing field to trigger the Activity launch.
+ _scenario.value
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/AutomationHolders.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/AutomationHolders.kt
new file mode 100644
index 0000000000..559290d33d
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/AutomationHolders.kt
@@ -0,0 +1,33 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util
+
+import android.app.Instrumentation
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+
+internal object InstrumentationHolder {
+
+ val instrumentation: Instrumentation by lazy { InstrumentationRegistry.getInstrumentation() }
+}
+
+internal object UiDeviceHolder {
+
+ val uiDevice: UiDevice by lazy { UiDevice.getInstance(InstrumentationHolder.instrumentation) }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Await.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Await.kt
new file mode 100644
index 0000000000..623c7dbefd
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Await.kt
@@ -0,0 +1,56 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.onNodeWithTag
+import me.proton.core.compose.component.PROTON_PROGRESS_TEST_TAG
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+
+fun ComposeTestRule.awaitProgressIsHidden() {
+ onNodeWithTag(PROTON_PROGRESS_TEST_TAG)
+ .awaitHidden(this)
+}
+
+fun SemanticsNodeInteraction.awaitDisplayed(
+ composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule,
+ timeout: Duration = 10.seconds
+): SemanticsNodeInteraction = also {
+ composeTestRule.waitUntil(timeout.inWholeMilliseconds) { nodeIsDisplayed(this) }
+}
+
+fun SemanticsNodeInteraction.awaitHidden(
+ composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule,
+ timeout: Duration = 5.seconds
+): SemanticsNodeInteraction = also {
+ composeTestRule.waitUntil(timeout.inWholeMilliseconds) { nodeIsNotDisplayed(this) }
+}
+
+fun SemanticsNodeInteraction.awaitEnabled(
+ composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule,
+ timeout: Duration = 5.seconds
+): SemanticsNodeInteraction = apply {
+ composeTestRule.waitUntil(timeout.inWholeMilliseconds) {
+ runCatching { assertIsEnabled() }.isSuccess
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Checks.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Checks.kt
new file mode 100644
index 0000000000..924a5796d9
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Checks.kt
@@ -0,0 +1,40 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertIsDisplayed
+
+fun nodeIsDisplayed(interaction: SemanticsNodeInteraction): Boolean {
+ try {
+ interaction.assertIsDisplayed()
+ } catch (ignored: AssertionError) {
+ return false
+ }
+ return true
+}
+
+fun nodeIsNotDisplayed(interaction: SemanticsNodeInteraction): Boolean {
+ try {
+ interaction.assertIsDisplayed()
+ } catch (ignored: AssertionError) {
+ return true
+ }
+ return false
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/HiltInstrumentedTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/HiltInstrumentedTest.kt
new file mode 100644
index 0000000000..57e2ae9f25
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/HiltInstrumentedTest.kt
@@ -0,0 +1,47 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util
+
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import ch.protonmail.android.test.utils.ComposeTestRuleHolder
+import ch.protonmail.android.uitest.rule.MainInitializerRule
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.rules.RuleChain
+
+@HiltAndroidTest
+open class HiltInstrumentedTest {
+
+ private val hiltTestRule = HiltAndroidRule(this)
+ private val mainInitializerRule = MainInitializerRule()
+ val composeTestRule: ComposeContentTestRule = ComposeTestRuleHolder.createAndGetComposeRule()
+
+ @get:Rule
+ val ruleChain: RuleChain = RuleChain
+ .outerRule(hiltTestRule)
+ .around(composeTestRule)
+ .around(mainInitializerRule)
+
+ @Before
+ fun setup() {
+ hiltTestRule.inject()
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Interactions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Interactions.kt
new file mode 100644
index 0000000000..699724fea1
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Interactions.kt
@@ -0,0 +1,76 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util
+
+import androidx.annotation.StringRes
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.SemanticsNodeInteractionCollection
+import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
+import androidx.compose.ui.test.onAllNodesWithContentDescription
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import ch.protonmail.android.mailcommon.presentation.model.TextUiModel
+
+fun SemanticsNodeInteractionsProvider.onAllNodesWithText(
+ text: TextUiModel,
+ substring: Boolean = false,
+ ignoreCase: Boolean = false,
+ useUnmergedTree: Boolean = false
+): SemanticsNodeInteractionCollection = onAllNodesWithText(getString(text), substring, ignoreCase, useUnmergedTree)
+
+fun SemanticsNodeInteractionsProvider.onAllNodesWithText(
+ @StringRes textRes: Int,
+ substring: Boolean = false,
+ ignoreCase: Boolean = false,
+ useUnmergedTree: Boolean = false
+): SemanticsNodeInteractionCollection = onAllNodesWithText(getString(textRes), substring, ignoreCase, useUnmergedTree)
+
+fun SemanticsNodeInteractionsProvider.onNodeWithContentDescription(
+ @StringRes labelRes: Int,
+ substring: Boolean = false,
+ ignoreCase: Boolean = false,
+ useUnmergedTree: Boolean = false
+): SemanticsNodeInteraction = onNodeWithContentDescription(getString(labelRes), substring, ignoreCase, useUnmergedTree)
+
+fun SemanticsNodeInteractionsProvider.onAllNodesWithContentDescription(
+ @StringRes labelRes: Int,
+ substring: Boolean = false,
+ ignoreCase: Boolean = false,
+ useUnmergedTree: Boolean = false
+): SemanticsNodeInteractionCollection = onAllNodesWithContentDescription(
+ label = getString(labelRes),
+ substring = substring,
+ ignoreCase = ignoreCase,
+ useUnmergedTree = useUnmergedTree
+)
+
+fun SemanticsNodeInteractionsProvider.onNodeWithText(
+ text: TextUiModel,
+ substring: Boolean = false,
+ ignoreCase: Boolean = false,
+ useUnmergedTree: Boolean = false
+): SemanticsNodeInteraction = onNodeWithText(getString(text), substring, ignoreCase, useUnmergedTree)
+
+fun SemanticsNodeInteractionsProvider.onNodeWithText(
+ @StringRes textRes: Int,
+ substring: Boolean = false,
+ ignoreCase: Boolean = false,
+ useUnmergedTree: Boolean = false
+): SemanticsNodeInteraction = onNodeWithText(getString(textRes), substring, ignoreCase, useUnmergedTree)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Locators.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Locators.kt
new file mode 100644
index 0000000000..c8f3f9600f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Locators.kt
@@ -0,0 +1,40 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util
+
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.SemanticsNodeInteractionCollection
+import androidx.compose.ui.test.filter
+import androidx.compose.ui.test.onChildren
+import androidx.compose.ui.test.onFirst
+
+/**
+ * Returns a child [SemanticsNodeInteraction] from another [SemanticsNodeInteraction]
+ * by filtering the parent's children with the given [SemanticsMatcher].
+ */
+fun SemanticsNodeInteraction.child(matcher: () -> SemanticsMatcher): SemanticsNodeInteraction =
+ onChildren().filter(matcher.invoke()).onFirst()
+
+/**
+ * Returns a [SemanticsNodeInteractionCollection] from a [SemanticsNodeInteraction]
+ * by filtering the parent's children with the given [SemanticsMatcher].
+ */
+fun SemanticsNodeInteraction.children(matcher: () -> SemanticsMatcher): SemanticsNodeInteractionCollection =
+ onChildren().filter(matcher.invoke())
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Matchers.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Matchers.kt
new file mode 100644
index 0000000000..20f85ba532
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Matchers.kt
@@ -0,0 +1,53 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util
+
+import androidx.annotation.StringRes
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.text.TextLayoutResult
+
+fun hasText(
+ @StringRes textRes: Int,
+ substring: Boolean = false,
+ ignoreCase: Boolean = false
+): SemanticsMatcher = hasText(getString(textRes), substring, ignoreCase)
+
+// Not as straightforward, some bits are taken from the compose-ui source code which can be found here:
+// https://github.com/androidx/androidx/blob/3606267939d1cb78310a1e40e76f673920f277b8/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt#L1840-L1849
+fun hasTextColor(color: Color): SemanticsMatcher {
+ val semanticsAction = SemanticsActions.GetTextLayoutResult
+
+ return SemanticsMatcher(
+ description = "${SemanticsProperties.Text.name} color matches '$color'"
+ ) {
+ val textLayoutElements = mutableListOf().apply {
+ it.config[semanticsAction].action?.invoke(this)
+ } as List
+
+ if (textLayoutElements.isNotEmpty()) {
+ textLayoutElements[0].layoutInput.style.color == color
+ } else {
+ false
+ }
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/ResourcesUtils.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/ResourcesUtils.kt
new file mode 100644
index 0000000000..5231b830aa
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/ResourcesUtils.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.util
+
+import android.app.Application
+import androidx.annotation.PluralsRes
+import androidx.annotation.StringRes
+import androidx.test.core.app.ApplicationProvider
+import ch.protonmail.android.mailcommon.presentation.model.TextUiModel
+import ch.protonmail.android.uitest.util.InstrumentationHolder.instrumentation
+
+fun getString(text: TextUiModel): String = when (text) {
+ is TextUiModel.Text -> text.value
+ is TextUiModel.TextRes -> getString(text.value)
+ is TextUiModel.TextResWithArgs -> getString(text.value, *text.formatArgs.toTypedArray())
+ is TextUiModel.PluralisedText -> getTestString(text.value, text.value)
+}
+
+fun getString(@StringRes resId: Int): String = ApplicationProvider.getApplicationContext().getString(resId)
+
+fun getString(@StringRes resId: Int, vararg formatArgs: Any): String =
+ ApplicationProvider.getApplicationContext().getString(resId, formatArgs)
+
+fun getTestString(@StringRes resId: Int, vararg formatArgs: Any): String =
+ instrumentation.context.getString(resId, *formatArgs)
+
+fun getTestString(@PluralsRes pluralResId: Int, value: Int): String =
+ instrumentation.context.resources.getQuantityString(pluralResId, value, value)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/StateManager.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/StateManager.kt
new file mode 100644
index 0000000000..f8a3f7cf21
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/StateManager.kt
@@ -0,0 +1,87 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import arrow.core.NonEmptyList
+import arrow.core.nonEmptyListOf
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class StateManager(private val states: NonEmptyList) {
+
+ private var index = 0
+ val flow: MutableStateFlow = MutableStateFlow(states[index])
+
+ fun emit(state: State) {
+ flow.value = state
+ }
+
+ fun emitNext() {
+ if (index == states.lastIndex) {
+ throw IllegalStateException("No more states to emit")
+ }
+ flow.value = states[++index]
+ }
+
+ companion object {
+
+ fun of(initialState: State, vararg nextStates: State): StateManager =
+ StateManager(nonEmptyListOf(initialState, *nextStates))
+
+ fun of(states: NonEmptyList): StateManager =
+ StateManager(states = states)
+ }
+}
+
+interface ManagedStateScope {
+
+ fun emitState(state: State)
+
+ fun emitNextState()
+}
+
+@Composable
+fun ManagedState(
+ stateManager: StateManager,
+ content: @Composable ManagedStateScope.(state: State) -> Unit
+) {
+ val scope = object : ManagedStateScope {
+ override fun emitState(state: State) {
+ stateManager.emit(state)
+ }
+
+ override fun emitNextState() {
+ stateManager.emitNext()
+ }
+ }
+ val state by stateManager.flow.collectAsState()
+ scope.content(state)
+}
+
+fun ComposeContentTestRule.setManagedStateContent(
+ stateManager: StateManager,
+ content: @Composable ManagedStateScope.(state: State) -> Unit
+) {
+ setContent {
+ ManagedState(stateManager, content)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/StringUtils.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/StringUtils.kt
new file mode 100644
index 0000000000..abdbc39dfa
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/StringUtils.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.uitest.util
+
+internal object StringUtils {
+
+ fun generateRandomString(length: Int): String {
+ val allowedRange = ('a'..'z') + ('A'..'Z') + ('0'..'9') + ' '
+ return (1..length).map { allowedRange.random() }.joinToString("")
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/CustomMailboxAssertions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/CustomMailboxAssertions.kt
new file mode 100644
index 0000000000..b32fd7de00
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/CustomMailboxAssertions.kt
@@ -0,0 +1,31 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util.assertions
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import ch.protonmail.android.uitest.util.extensions.getKeyValueByName
+import org.junit.Assert.assertEquals
+
+internal fun SemanticsNodeInteraction.assertItemIsRead(expectedValue: Boolean) = apply {
+ val isItemReadProperty = requireNotNull(getKeyValueByName(CustomSemanticsPropertyKeyNames.IsItemReadKey)) {
+ "Expected IsItemReadKey property was not found on this node."
+ }
+
+ assertEquals(expectedValue, isItemReadProperty.value)
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/CustomSemanticsPropertyKeyNames.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/CustomSemanticsPropertyKeyNames.kt
new file mode 100644
index 0000000000..14bb2d312a
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/CustomSemanticsPropertyKeyNames.kt
@@ -0,0 +1,26 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util.assertions
+
+internal object CustomSemanticsPropertyKeyNames {
+
+ const val TintColorKey = "TintColorKey"
+ const val IsItemReadKey = "IsItemReadKey"
+ const val IsValidFieldKey = "IsValidFieldKey"
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/SemanticsNodeInteractionChildAssertions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/SemanticsNodeInteractionChildAssertions.kt
new file mode 100644
index 0000000000..adf76c03b8
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/SemanticsNodeInteractionChildAssertions.kt
@@ -0,0 +1,61 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util.assertions
+
+import android.util.Log
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.onChildAt
+import androidx.compose.ui.test.printToString
+
+/**
+ * Traverses the UI hierarchy starting from the receiving [SemanticsNodeInteraction] to find a
+ * matching child with the given [SemanticsMatcher].
+ *
+ * Note that only the first child of a given sub-hierarchy will be traversed.
+ *
+ * @param matcher the matcher to match the child element with.
+ * @param maxDepthLevel the maximum level of depth.
+ *
+ * @throws AssertionError if no child is found within the [maxDepthLevel].
+ */
+fun SemanticsNodeInteraction.hasAnyChildWith(matcher: SemanticsMatcher, maxDepthLevel: Int = 5) {
+ val logTag = "SemanticsNodeInteraction#hasAnyChildWith"
+ var child = onChildAt(0)
+
+ for (attempt in 1.rangeTo(maxDepthLevel)) {
+ try {
+ child.assert(matcher)
+ Log.d(logTag, "Found matching element at attempt $attempt.")
+ return
+ } catch (e: AssertionError) {
+ Log.d(logTag, "Attempt #$attempt - Unable to find a child with matcher '$matcher'.")
+ Log.d(logTag, "${e.message}")
+ child = child.onChildAt(0)
+ }
+ }
+
+ throw AssertionError(
+ "Unable to find any direct zero-indexed child of the given node within $maxDepthLevel level(s) " +
+ "of depth with matcher '${matcher.description}'."
+ ).also {
+ Log.e(logTag, "Dumping first ancestor hierarchy...\n\n${printToString()}")
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/TextAssertions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/TextAssertions.kt
new file mode 100644
index 0000000000..9137ce926f
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/TextAssertions.kt
@@ -0,0 +1,71 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util.assertions
+
+import androidx.annotation.StringRes
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.SemanticsPropertyKey
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertTextContains
+import androidx.compose.ui.test.assertTextEquals
+import ch.protonmail.android.uitest.util.extensions.getKeyValueByName
+import ch.protonmail.android.uitest.util.getString
+import ch.protonmail.android.uitest.util.hasTextColor
+import kotlin.test.assertEquals
+
+fun SemanticsNodeInteraction.assertTextColor(color: Long): SemanticsNodeInteraction = assertTextColor(Color(color))
+
+fun SemanticsNodeInteraction.assertTextColor(color: Color): SemanticsNodeInteraction = assert(hasTextColor(color))
+
+fun SemanticsNodeInteraction.assertEmptyText() = assertTextEquals("")
+
+fun SemanticsNodeInteraction.assertTextContains(
+ @StringRes valueRes: Int,
+ substring: Boolean = false,
+ ignoreCase: Boolean = false
+): SemanticsNodeInteraction = assertTextContains(getString(valueRes), substring, ignoreCase)
+
+/**
+ * Performs an assertion against the content of the receiving node
+ * excluding the content of the `EditableText` [SemanticsPropertyKey] .
+ *
+ * This is useful when performing checks against nodes located with the merged tree strategy.
+ *
+ * @param value the expected [String] value.
+ */
+fun SemanticsNodeInteraction.assertNotEditableTextEquals(value: String) {
+ assertTextEquals(value, includeEditableText = false)
+}
+
+/**
+ * Performs an assertion against the content of the `EditableText` [SemanticsPropertyKey] of the receiving node.
+ *
+ * This is useful when performing checks against nodes located with the merged tree strategy.
+ *
+ * @param value the expected [String] value.
+ */
+fun SemanticsNodeInteraction.assertEditableTextEquals(value: String) {
+ val editableText = requireNotNull(getKeyValueByName(SemanticsProperties.EditableText.name)) {
+ "Expected EditableText property was not found on this node."
+ }
+
+ assertEquals(value, editableText.value.toString())
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/TintColorAssertions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/TintColorAssertions.kt
new file mode 100644
index 0000000000..0cf99a252e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/TintColorAssertions.kt
@@ -0,0 +1,36 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util.assertions
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import ch.protonmail.android.uitest.models.folders.Tint
+import ch.protonmail.android.uitest.util.extensions.getKeyValueByName
+import org.junit.Assert.assertEquals
+import kotlin.test.assertNull
+
+internal fun SemanticsNodeInteraction.assertTintColor(tint: Tint) = apply {
+ val tintColorProperty = requireNotNull(getKeyValueByName(CustomSemanticsPropertyKeyNames.TintColorKey)) {
+ "Expected TintColorKey property was not found on this node."
+ }
+
+ when (tint) {
+ is Tint.WithColor -> assertEquals(tint.value, tintColorProperty.value)
+ is Tint.NoColor -> assertNull(tintColorProperty.value)
+ }
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/IdResExtensions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/IdResExtensions.kt
new file mode 100644
index 0000000000..0ad8a09cbf
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/IdResExtensions.kt
@@ -0,0 +1,33 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util.extensions
+
+import androidx.annotation.IdRes
+import ch.protonmail.android.uitest.util.InstrumentationHolder.instrumentation
+
+/**
+ * The resource ID value as full [String] in the format `:id/`.
+ *
+ * Example:
+ * ```
+ * R.id.scrollContent.asStringResourceId -> "ch.protonmail.android.dev:id/scrollContent"
+ * ```
+ */
+internal val @receiver:IdRes Int.asStringResourceId: String
+ get() = instrumentation.targetContext.resources.getResourceName(this)
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/LoginRobotExtensions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/LoginRobotExtensions.kt
new file mode 100644
index 0000000000..d870f754fe
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/LoginRobotExtensions.kt
@@ -0,0 +1,34 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util.extensions
+
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import ch.protonmail.android.R
+import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice
+import me.proton.core.test.android.robots.auth.login.LoginRobot
+
+// This is needed as from the Login screen to the Mailbox, we switch from XML views to Compose layouts.
+// If the Compose layout is not ready yet, checks performed on the Compose test rule
+// might throw an IllegalStateException, making the test fail.
+@Suppress("UnusedReceiverParameter")
+fun LoginRobot.waitUntilSignInScreenIsGone(timeout: Long = 15_000L) {
+ // R.id.scrollContent is the root item in the Sign In XML screen (located in the Core library)
+ uiDevice.wait(Until.gone(By.res(R.id.scrollContent.asStringResourceId)), timeout)
+}
diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/SemanticsNodeInteractionExtensions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/SemanticsNodeInteractionExtensions.kt
new file mode 100644
index 0000000000..0f6948046e
--- /dev/null
+++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/SemanticsNodeInteractionExtensions.kt
@@ -0,0 +1,30 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.uitest.util.extensions
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+
+/**
+ * Returns a [Map.Entry] instance for the provided key, null if no entry exists.
+ */
+fun SemanticsNodeInteraction.getKeyValueByName(key: String): Map.Entry<*, *>? {
+ return fetchSemanticsNode().config.firstOrNull {
+ it.key.name == key
+ }
+}
diff --git a/app/src/uiTest/res/values/strings.xml b/app/src/uiTest/res/values/strings.xml
new file mode 100644
index 0000000000..a24a49509d
--- /dev/null
+++ b/app/src/uiTest/res/values/strings.xml
@@ -0,0 +1,109 @@
+
+
+
+
+ Exit selection mode
+ Menu
+ Search
+ Exit search mode
+
+
+ Inbox
+ Drafts
+ Sent
+ Starred
+ Archive
+ Spam
+ Trash
+ All mail
+ (No Sender)
+ (No Recipient)
+ Official
+
+
+ This mailbox is empty
+ Loading mailbox failed
+ Draft saved
+ Sending message…
+ Offline, message queued for sending
+ Message sent
+ Error sending message
+
+
+ Unable to retrieve message
+
+
+ Automatic loading of embedded images is turned off. This can be changed in the settings.
+ Automatic loading of remote content is turned off. This can be changed in the settings.
+ Automatic loading of embedded images and remote content is turned off. These can be changed in the settings.
+
+
+ From:
+ To:
+ Cc:
+ Bcc:
+ Subject
+ Compose email
+ Email address is invalid
+ Removed duplicate recipient: %1$s
+ You need a paid Proton Mail subscription to change the sender address
+ The content of this draft is not available at this time. Editing will override any pre-existing content.
+ Error uploading attachment
+
+
+ Mark read
+ Mark unread
+ Star
+ Unstar
+ Label as…
+ Move to…
+ Trash
+ Delete
+ Archive
+ Spam
+ More
+
+
+ Done
+
+
+ Move to...
+
+ Feature coming soon...
+ Conversation moved to %1$s
+
+
+ Settings
+ Report a problem
+ Subscription
+ Sign out
+
+
+ Retry
+
+
+ Current download must complete before starting a new one.
+
+
+ Message decryption failed.
+
+
+ Sign out
+ Cancel
+
diff --git a/benchmark/.gitignore b/benchmark/.gitignore
new file mode 100644
index 0000000000..75bcea80da
--- /dev/null
+++ b/benchmark/.gitignore
@@ -0,0 +1,2 @@
+/build
+benchmark.properties
\ No newline at end of file
diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts
new file mode 100644
index 0000000000..7ec3904615
--- /dev/null
+++ b/benchmark/build.gradle.kts
@@ -0,0 +1,95 @@
+import java.util.Properties
+
+/*
+ * 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 .
+ */
+
+plugins {
+ id("com.android.test")
+ id("org.jetbrains.kotlin.android")
+}
+
+private val benchmarkProperties = Properties().apply {
+ @Suppress("SwallowedException")
+ try {
+ load(projectDir.resolve("benchmark.properties").inputStream())
+ } catch (exception: java.io.FileNotFoundException) {
+ Properties()
+ }
+}
+
+private val benchmarkUsername = benchmarkProperties["username"].toString()
+private val benchmarkPassword = benchmarkProperties["password"].toString()
+
+android {
+ namespace = "ch.protonmail.android.benchmark"
+ compileSdk = Config.compileSdk
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ defaultConfig {
+ minSdk = Config.minSdk
+ targetSdk = Config.targetSdk
+
+ missingDimensionStrategy("default", "alpha")
+
+ buildConfigField("String", "DEFAULT_LOGIN", benchmarkUsername.toBuildConfigValue())
+ buildConfigField("String", "DEFAULT_PASSWORD", benchmarkPassword.toBuildConfigValue())
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ // This benchmark buildType is used for benchmarking, and should function like the
+ // release build (for example, with minification on). It's signed with a debug key
+ // for easy local/CI testing.
+ create("benchmark") {
+ isDebuggable = true
+ signingConfig = getByName("debug").signingConfig
+ matchingFallbacks += listOf("release")
+ }
+ }
+
+ buildFeatures {
+ buildConfig = true
+ }
+
+ targetProjectPath = ":app"
+ experimentalProperties["android.experimental.self-instrumenting"] = true
+}
+
+dependencies {
+ implementation(libs.androidx.test.androidjunit)
+ implementation(libs.androidx.test.espresso.core)
+ implementation(libs.androidx.test.uiautomator)
+ implementation(libs.androidx.test.macrobenchmark)
+}
+
+androidComponents {
+ beforeVariants(selector().all()) {
+ it.enable = it.buildType == "benchmark"
+ }
+}
+
+fun String?.toBuildConfigValue() = if (this != null) "\"$this\"" else "null"
diff --git a/benchmark/src/main/AndroidManifest.xml b/benchmark/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..8914ea6ad6
--- /dev/null
+++ b/benchmark/src/main/AndroidManifest.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/BenchmarkConfig.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/BenchmarkConfig.kt
new file mode 100644
index 0000000000..3bf3709862
--- /dev/null
+++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/BenchmarkConfig.kt
@@ -0,0 +1,28 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.benchmark.common
+
+object BenchmarkConfig {
+
+ const val PackageName = "ch.protonmail.android.alpha"
+ const val WaitForLoginToDisappearTimeout = 15_000L
+ const val WaitForMailboxTimeout = 20_000L
+ const val WaitForMessageDetailsTimeout = 10_000L
+ const val DefaultIterations = 5
+}
diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/BenchmarkTraceUtils.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/BenchmarkTraceUtils.kt
new file mode 100644
index 0000000000..3472f62901
--- /dev/null
+++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/BenchmarkTraceUtils.kt
@@ -0,0 +1,43 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.benchmark.common
+
+import androidx.benchmark.macro.ExperimentalMetricApi
+import androidx.benchmark.macro.TraceSectionMetric
+
+/**
+ * Core trace sections to be benchmarked.
+ */
+@OptIn(ExperimentalMetricApi::class)
+fun coreTraceSectionsList(): List {
+ return listOf(
+ TraceSectionMetric("proton-app-init")
+ )
+}
+
+/**
+ * Remote Api trace sections to be benchmarked.
+ */
+@OptIn(ExperimentalMetricApi::class)
+fun remoteApiTraceSectionsList(): List {
+ return listOf(
+ TraceSectionMetric("proton-api-get-conversations"),
+ TraceSectionMetric("proton-api-get-messages")
+ )
+}
diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/MailboxUtils.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/MailboxUtils.kt
new file mode 100644
index 0000000000..e8df684dee
--- /dev/null
+++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/MailboxUtils.kt
@@ -0,0 +1,57 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.benchmark.common
+
+import androidx.benchmark.macro.MacrobenchmarkScope
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+
+/**
+ * Wait until the first row within the mailbox list is rendered.
+ */
+fun MacrobenchmarkScope.waitUntilFirstEmailRowShownOnMailboxList() {
+ device.wait(Until.hasObject(By.res(TestTags.MailboxListTag)), BenchmarkConfig.WaitForMailboxTimeout)
+
+ val mailboxList = device.findObject(By.res(TestTags.MailboxListTag))
+
+ // Wait until the first row within the list is rendered
+ mailboxList.wait(Until.hasObject(By.res(TestTags.FirstMailboxItemRow)), BenchmarkConfig.WaitForMailboxTimeout)
+}
+
+
+/**
+ * Click on the first row and wait until message details are shown.
+ */
+fun MacrobenchmarkScope.clickOnTheFirstEmailRowWaitDetailsShown() {
+
+ val mailboxList = device.findObject(By.res(TestTags.MailboxListTag))
+
+ val firstEmailRow = mailboxList.findObject(By.res(TestTags.FirstMailboxItemRow))
+
+ firstEmailRow.click()
+
+ device.wait(Until.hasObject(By.res(TestTags.MessageBodyNoWebView)), BenchmarkConfig.WaitForMessageDetailsTimeout)
+}
+
+/**
+ * Wait until the mailbox list is rendered but emails are not loaded.
+ */
+fun MacrobenchmarkScope.waitUntilMailboxShownButEmailsNotLoaded() {
+ device.wait(Until.hasObject(By.res(TestTags.MailboxRootTag)), BenchmarkConfig.WaitForMailboxTimeout)
+}
diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/StartupUtils.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/StartupUtils.kt
new file mode 100644
index 0000000000..50c8e2d7a4
--- /dev/null
+++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/StartupUtils.kt
@@ -0,0 +1,67 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.benchmark.common
+
+import android.widget.EditText
+import androidx.benchmark.macro.MacrobenchmarkScope
+import ch.protonmail.android.benchmark.BuildConfig
+import ch.protonmail.android.benchmark.common.extensions.findUiObjectByClassWithParent
+import ch.protonmail.android.benchmark.common.extensions.findUiObjectByResource
+import ch.protonmail.android.benchmark.common.extensions.findUiObjectByText
+import ch.protonmail.android.benchmark.common.extensions.waitUntilGone
+import ch.protonmail.android.benchmark.common.identifiers.ResourceIdentifiers
+import ch.protonmail.android.benchmark.common.identifiers.TextIdentifiers
+
+internal fun MacrobenchmarkScope.performLogin(
+ username: String = BuildConfig.DEFAULT_LOGIN,
+ password: String = BuildConfig.DEFAULT_PASSWORD
+) {
+ // To be refactored with the Robot pattern.
+ with(device) {
+ findUiObjectByResource(ResourceIdentifiers.SignInButton).click()
+
+ findUiObjectByClassWithParent(EditText::class.java, ResourceIdentifiers.UsernameInput)
+ .setText(username)
+
+ findUiObjectByClassWithParent(EditText::class.java, ResourceIdentifiers.PasswordInput)
+ .setText(password)
+
+ findUiObjectByResource(ResourceIdentifiers.PerformSignInButton).click()
+
+ // Permission handling needs to be done here for now since the Login root view is still displayed underneath.
+ runCatching {
+ // TBC if this breaks as it depends on platform specific ids, which might change depending on the device.
+ device.findUiObjectByResource(ResourceIdentifiers.AllowPermission).click()
+ }
+
+ waitUntilGone(ResourceIdentifiers.LoginScreenRootView, BenchmarkConfig.WaitForLoginToDisappearTimeout)
+ }
+}
+
+internal fun MacrobenchmarkScope.skipOnboarding() {
+ val expectedOnboardingPages = 3
+
+ with(device) {
+ repeat(expectedOnboardingPages) {
+ findUiObjectByText(TextIdentifiers.OnboardingScreenButtonText).click()
+ }
+
+ findUiObjectByText(TextIdentifiers.OnboardingCompleteButtonText).click()
+ }
+}
diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/TestTags.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/TestTags.kt
new file mode 100644
index 0000000000..6ef805f17a
--- /dev/null
+++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/TestTags.kt
@@ -0,0 +1,29 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.benchmark.common
+
+/**
+ * Test Tags (which are used as resource ids)
+ */
+object TestTags {
+ const val MailboxRootTag = "MailboxScreen"
+ const val FirstMailboxItemRow = "MailboxItemRow0"
+ const val MailboxListTag = "MailboxList"
+ const val MessageBodyNoWebView = "MessageBodyNoWebView"
+}
diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/extensions/UiDeviceExtensions.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/extensions/UiDeviceExtensions.kt
new file mode 100644
index 0000000000..ff72bd3cdc
--- /dev/null
+++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/extensions/UiDeviceExtensions.kt
@@ -0,0 +1,34 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.benchmark.common.extensions
+
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.UiSelector
+import androidx.test.uiautomator.Until
+
+internal fun UiDevice.findUiObjectByText(text: String) = findObject(UiSelector().text(text))
+
+internal fun UiDevice.findUiObjectByResource(resId: String) = findObject(UiSelector().resourceId(resId))
+
+internal fun UiDevice.findUiObjectByClassWithParent(childClass: Class<*>, parentResourceId: String) = findObject(
+ UiSelector().resourceId(parentResourceId).childSelector(UiSelector().className(childClass))
+)
+
+internal fun UiDevice.waitUntilGone(resId: String, timeout: Long) = wait(Until.gone(By.res(resId)), timeout)
diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/identifiers/ResourceIdentifiers.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/identifiers/ResourceIdentifiers.kt
new file mode 100644
index 0000000000..976fc4247e
--- /dev/null
+++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/identifiers/ResourceIdentifiers.kt
@@ -0,0 +1,33 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.benchmark.common.identifiers
+
+import ch.protonmail.android.benchmark.common.BenchmarkConfig
+
+internal object ResourceIdentifiers {
+
+ private const val PackageName = BenchmarkConfig.PackageName
+
+ const val SignInButton = "$PackageName:id/sign_in"
+ const val UsernameInput = "$PackageName:id/usernameInput"
+ const val PasswordInput = "$PackageName:id/passwordInput"
+ const val PerformSignInButton = "$PackageName:id/signInButton"
+ const val AllowPermission = "com.android.permissioncontroller:id/permission_allow_button"
+ const val LoginScreenRootView = "$PackageName:id/scrollContent"
+}
diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/identifiers/TextIdentifiers.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/identifiers/TextIdentifiers.kt
new file mode 100644
index 0000000000..201632f697
--- /dev/null
+++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/identifiers/TextIdentifiers.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.benchmark.common.identifiers
+
+internal object TextIdentifiers {
+
+ const val OnboardingScreenButtonText = "Next"
+ const val OnboardingCompleteButtonText = "Get started"
+}
diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/convdetail/ConversationDetailsBenchmark.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/convdetail/ConversationDetailsBenchmark.kt
new file mode 100644
index 0000000000..27f674393a
--- /dev/null
+++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/convdetail/ConversationDetailsBenchmark.kt
@@ -0,0 +1,85 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.benchmark.convdetail
+
+import androidx.benchmark.macro.ExperimentalMetricApi
+import androidx.benchmark.macro.FrameTimingMetric
+import androidx.benchmark.macro.StartupMode
+import androidx.benchmark.macro.StartupTimingMetric
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import ch.protonmail.android.benchmark.common.BenchmarkConfig
+import ch.protonmail.android.benchmark.common.clickOnTheFirstEmailRowWaitDetailsShown
+import ch.protonmail.android.benchmark.common.coreTraceSectionsList
+import ch.protonmail.android.benchmark.common.performLogin
+import ch.protonmail.android.benchmark.common.remoteApiTraceSectionsList
+import ch.protonmail.android.benchmark.common.skipOnboarding
+import ch.protonmail.android.benchmark.common.waitUntilFirstEmailRowShownOnMailboxList
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class ConversationDetailsBenchmark {
+
+ @get:Rule
+ val benchmarkRule = MacrobenchmarkRule()
+
+ /**
+ * Start the application
+ * Wait for the mailbox to be visible and messages loaded
+ * Click on the first conversation
+ * Wait for conversation details to be visible
+ */
+ @OptIn(ExperimentalMetricApi::class)
+ @Test
+ fun testLoadingConversationDetailsScreen() {
+ var firstStart = true
+
+ benchmarkRule.measureRepeated(
+ packageName = BenchmarkConfig.PackageName,
+ metrics = listOf(
+ StartupTimingMetric(),
+ FrameTimingMetric()
+ ) + coreTraceSectionsList() +
+ remoteApiTraceSectionsList(),
+ iterations = BenchmarkConfig.DefaultIterations,
+ startupMode = StartupMode.COLD,
+ setupBlock = {
+ if (!firstStart) return@measureRepeated
+
+ startActivityAndWait()
+ performLogin()
+ skipOnboarding()
+ firstStart = false
+
+ pressHome()
+ }
+ ) {
+
+ startActivityAndWait()
+
+ waitUntilFirstEmailRowShownOnMailboxList()
+
+ clickOnTheFirstEmailRowWaitDetailsShown()
+ }
+ }
+}
diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/scroll/ScrollMailboxBenchmark.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/scroll/ScrollMailboxBenchmark.kt
new file mode 100644
index 0000000000..5e648f3244
--- /dev/null
+++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/scroll/ScrollMailboxBenchmark.kt
@@ -0,0 +1,100 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.benchmark.scroll
+
+import androidx.benchmark.macro.ExperimentalMetricApi
+import androidx.benchmark.macro.FrameTimingMetric
+import androidx.benchmark.macro.StartupTimingMetric
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Direction
+import ch.protonmail.android.benchmark.common.BenchmarkConfig
+import ch.protonmail.android.benchmark.common.coreTraceSectionsList
+import ch.protonmail.android.benchmark.common.performLogin
+import ch.protonmail.android.benchmark.common.remoteApiTraceSectionsList
+import ch.protonmail.android.benchmark.common.skipOnboarding
+import ch.protonmail.android.benchmark.common.waitUntilFirstEmailRowShownOnMailboxList
+import junit.framework.TestCase
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * This is a benchmark that scrolls the mailbox list up and down and measures how long the frames took.
+ *
+ * It navigates to the device's home screen, and launches the default activity. Note that it does not go through
+ * the login, it expects a user to be already logged in before the benchmark is ran.
+ *
+ * In order to run, select the alphaBenchmark build variant for the app module.
+ */
+@RunWith(AndroidJUnit4::class)
+class ScrollMailboxBenchmark {
+
+ @get:Rule
+ val benchmarkRule = MacrobenchmarkRule()
+
+ @Test
+ fun scrollMailbox() = scroll()
+
+ @OptIn(ExperimentalMetricApi::class)
+ private fun scroll() {
+ var firstStart = true
+ benchmarkRule.measureRepeated(
+ packageName = BenchmarkConfig.PackageName,
+ metrics = listOf(
+ StartupTimingMetric(),
+ FrameTimingMetric()
+ ) + coreTraceSectionsList() +
+ remoteApiTraceSectionsList(),
+ startupMode = null,
+ iterations = BenchmarkConfig.DefaultIterations,
+ setupBlock = {
+ if (!firstStart) return@measureRepeated
+
+ startActivityAndWait()
+ performLogin()
+ skipOnboarding()
+ firstStart = false
+ }
+ ) {
+ waitUntilFirstEmailRowShownOnMailboxList()
+
+ val scrollableObject = device.findObject(By.scrollable(true))
+ if (scrollableObject == null) {
+ TestCase.fail("No scrollable view found in hierarchy")
+ }
+ scrollableObject.setGestureMargin(device.displayWidth / GestureMarginRatio)
+ scrollableObject?.apply {
+ repeat(NumberOfListFlings) {
+ fling(Direction.DOWN)
+ }
+ repeat(NumberOfListFlings) {
+ fling(Direction.UP)
+ }
+ }
+ }
+ }
+
+ companion object {
+
+ const val GestureMarginRatio = 10
+ const val NumberOfListFlings = 5
+ }
+}
diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/startup/StartupBenchmark.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/startup/StartupBenchmark.kt
new file mode 100644
index 0000000000..8abd7e1e30
--- /dev/null
+++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/startup/StartupBenchmark.kt
@@ -0,0 +1,106 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.benchmark.startup
+
+import androidx.benchmark.macro.ExperimentalMetricApi
+import androidx.benchmark.macro.FrameTimingMetric
+import androidx.benchmark.macro.StartupMode
+import androidx.benchmark.macro.StartupTimingMetric
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import ch.protonmail.android.benchmark.common.BenchmarkConfig
+import ch.protonmail.android.benchmark.common.coreTraceSectionsList
+import ch.protonmail.android.benchmark.common.performLogin
+import ch.protonmail.android.benchmark.common.remoteApiTraceSectionsList
+import ch.protonmail.android.benchmark.common.skipOnboarding
+import ch.protonmail.android.benchmark.common.waitUntilFirstEmailRowShownOnMailboxList
+import ch.protonmail.android.benchmark.common.waitUntilMailboxShownButEmailsNotLoaded
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class StartupBenchmark {
+
+ @get:Rule
+ val benchmarkRule = MacrobenchmarkRule()
+
+ /**
+ * Measure the cold startup time when the mailbox is visible but emails are not loaded.
+ */
+ @OptIn(ExperimentalMetricApi::class)
+ @Test
+ fun coldStartMailboxVisibleNoEmailsLoaded() = benchmarkRule.measureRepeated(
+ packageName = BenchmarkConfig.PackageName,
+ metrics = listOf(
+ StartupTimingMetric(),
+ FrameTimingMetric()
+ ) + coreTraceSectionsList(),
+ iterations = BenchmarkConfig.DefaultIterations,
+ startupMode = StartupMode.COLD,
+ setupBlock = {
+ pressHome()
+ }
+ ) {
+
+ startActivityAndWait()
+
+ waitUntilMailboxShownButEmailsNotLoaded()
+ }
+
+ /**
+ * Measure the cold startup time when the mailbox is visible and emails are loaded.
+ * We do not wait for all emails to be loaded from network, we only wait for the first
+ * one to be shown in the mailbox.
+ *
+ * At that point,we mark MainActivity to be fully drawn.
+ */
+ @OptIn(ExperimentalMetricApi::class)
+ @Test
+ fun coldStartMailboxVisibleWithEmailsLoaded() {
+ var firstStart = true
+ benchmarkRule.measureRepeated(
+ packageName = BenchmarkConfig.PackageName,
+ metrics = listOf(
+ StartupTimingMetric(),
+ FrameTimingMetric()
+ ) + coreTraceSectionsList() +
+ remoteApiTraceSectionsList(),
+ iterations = BenchmarkConfig.DefaultIterations,
+ startupMode = StartupMode.COLD,
+ setupBlock = {
+ if (!firstStart) return@measureRepeated
+
+ startActivityAndWait()
+ performLogin()
+ skipOnboarding()
+ firstStart = false
+
+ pressHome()
+ }
+ ) {
+
+ startActivityAndWait()
+
+ waitUntilFirstEmailRowShownOnMailboxList()
+ }
+ }
+}
diff --git a/build.gradle b/build.gradle
deleted file mode 100644
index d4efb6264f..0000000000
--- a/build.gradle
+++ /dev/null
@@ -1,18 +0,0 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-buildscript {
- repositories {
- google()
- mavenCentral()
- }
- dependencies {
- classpath "com.android.tools.build:gradle:7.0.3"
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31"
-
- // NOTE: Do not place your application dependencies here; they belong
- // in the individual module build.gradle files
- }
-}
-
-task clean(type: Delete) {
- delete rootProject.buildDir
-}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000000..f14b26b0b0
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,121 @@
+/*
+ * 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 .
+ */
+
+buildscript {
+ repositories {
+ google()
+ }
+ dependencies {
+ classpath(libs.android.tools.build)
+ classpath(libs.kotlin.gradle)
+ classpath(libs.hilt.android.gradle)
+ classpath(libs.google.services)
+ classpath(libs.sentry.gradle)
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+plugins {
+ alias(libs.plugins.google.devtools.ksp) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.proton.core.detekt)
+ alias(libs.plugins.proton.core.coverage.config)
+ alias(libs.plugins.proton.core.coverage) apply false
+ alias(libs.plugins.proton.core.global.coverage) apply false
+ alias(libs.plugins.compose.compiler) apply false
+}
+
+subprojects {
+ if (project.findProperty("enableComposeCompilerReports") == "true") {
+ kotlinCompilerArgs(
+ "-P",
+ "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
+ project.layout.buildDirectory.asFile.get().absolutePath + "/compose_reports",
+ "-P",
+ "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
+ project.layout.buildDirectory.asFile.get().absolutePath + "/compose_metrics"
+ )
+ }
+
+ afterEvaluate {
+ dependencies {
+ configurations.findByName("detektPlugins")?.let {
+ add("detektPlugins", project(":detekt-rules"))
+ }
+ }
+ tasks.findByName("detekt")?.dependsOn(":detekt-rules:assemble")
+ }
+}
+
+protonDetekt {
+ threshold = 0
+}
+
+tasks.register("clean", Delete::class) {
+ delete(rootProject.layout.buildDirectory)
+}
+
+setupTests()
+
+kotlinCompilerArgs(
+ "-opt-in=kotlin.RequiresOptIn",
+ // Enables experimental Coroutines API.
+ "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
+ // Enables experimental Time (Turbine).
+ "-opt-in=kotlin.time.ExperimentalTime"
+)
+
+fun Project.kotlinCompilerArgs(vararg extraCompilerArgs: String) {
+ for (sub in subprojects) {
+ sub.tasks.withType {
+ kotlinOptions { freeCompilerArgs = freeCompilerArgs + extraCompilerArgs }
+ }
+ }
+}
+
+
+fun Project.setupTests() {
+ fun Project.isRootProject() = this@isRootProject.subprojects.size != 0
+
+ for (sub in subprojects) {
+
+ // Apply coverage plugin to non subprojects.
+ if (!sub.isRootProject()) {
+ sub.afterEvaluate { pluginManager.apply("me.proton.core.gradle-plugins.coverage") }
+ }
+
+ sub.tasks.withType {
+ // Test logging
+ testLogging {
+ exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
+ }
+
+ // Additional JVM args to bypass strong encapsulation (needed for mocking)
+ jvmArgs(
+ "--add-opens", "java.base/java.util=ALL-UNNAMED"
+ )
+ }
+ }
+}
+
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
new file mode 100644
index 0000000000..1ea6fd03e8
--- /dev/null
+++ b/buildSrc/build.gradle.kts
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ `kotlin-dsl`
+}
+
+repositories {
+ maven("https://plugins.gradle.org/m2/")
+}
diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt
new file mode 100644
index 0000000000..41633ce22f
--- /dev/null
+++ b/buildSrc/src/main/kotlin/Config.kt
@@ -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 .
+ */
+
+object Config {
+ const val applicationId = "ch.protonmail.android"
+ const val compileSdk = 35
+ const val minSdk = 28
+ const val targetSdk = 34
+ const val testInstrumentationRunner = "ch.protonmail.android.uitest.HiltTestRunner"
+ const val versionCode = 1
+ const val versionName = "4.10.1"
+}
diff --git a/buildSrc/src/main/kotlin/Helpers.kt b/buildSrc/src/main/kotlin/Helpers.kt
new file mode 100644
index 0000000000..a93a718504
--- /dev/null
+++ b/buildSrc/src/main/kotlin/Helpers.kt
@@ -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 .
+ */
+
+import java.io.File
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.dependencies
+import kotlin.apply
+import kotlin.io.inputStream
+
+fun String.runCommand(
+ workingDir: File = File("."),
+ timeoutAmount: Long = 60,
+ timeoutUnit: TimeUnit = TimeUnit.SECONDS
+): String = ProcessBuilder(split("\\s(?=(?:[^'\"`]*(['\"`])[^'\"`]*\\1)*[^'\"`]*$)".toRegex()))
+ .directory(workingDir)
+ .redirectOutput(ProcessBuilder.Redirect.PIPE)
+ .redirectError(ProcessBuilder.Redirect.PIPE)
+ .start()
+ .apply { waitFor(timeoutAmount, timeoutUnit) }
+ .run {
+ val error = errorStream.bufferedReader().readText().trim()
+ if (error.isNotEmpty()) {
+ throw IOException(error)
+ }
+ inputStream.bufferedReader().readText().trim()
+ }
diff --git a/ci/cache-policy-pull.yml b/ci/cache-policy-pull.yml
new file mode 100644
index 0000000000..e63de71b70
--- /dev/null
+++ b/ci/cache-policy-pull.yml
@@ -0,0 +1,3 @@
+.cache-policy:
+ cache:
+ policy: pull
diff --git a/ci/cache-policy-push-pull.yml b/ci/cache-policy-push-pull.yml
new file mode 100644
index 0000000000..4635ceace0
--- /dev/null
+++ b/ci/cache-policy-push-pull.yml
@@ -0,0 +1,3 @@
+.cache-policy:
+ cache:
+ policy: pull-push
diff --git a/ci/templates/base-jobs.gitlab-ci.yml b/ci/templates/base-jobs.gitlab-ci.yml
new file mode 100644
index 0000000000..aac251b728
--- /dev/null
+++ b/ci/templates/base-jobs.gitlab-ci.yml
@@ -0,0 +1,36 @@
+.build_job:
+ stage: build
+ tags:
+ - android-xlarge
+ artifacts:
+ paths:
+ - app/build/outputs
+
+.firebase_test_job:
+ stage: test
+ dependencies:
+ - build_dev_debug
+ tags:
+ - shared-small
+ before_script:
+ - !reference [before_script]
+ - ./scripts/setup_firebase_gcloud.sh
+ cache:
+ policy: pull
+
+.firebase_deploy_job:
+ stage: deploy
+ interruptible: false
+ dependencies:
+ - build_alpha_release
+ tags:
+ - shared-small
+ before_script:
+ - !reference [before_script]
+ - echo $SERVICE_ACCOUNT_MAIL > /tmp/service-account.json
+ - ./scripts/release/generate_git_release_notes.sh /tmp/release_notes.txt
+ rules:
+ - if: $CI_PIPELINE_SOURCE == "parent_pipeline"
+ when: never
+ cache:
+ policy: pull
diff --git a/config/google-services/dummy-google-services.json b/config/google-services/dummy-google-services.json
new file mode 100644
index 0000000000..ef6cc4b921
--- /dev/null
+++ b/config/google-services/dummy-google-services.json
@@ -0,0 +1,167 @@
+{
+ "project_info": {
+ "project_number": "111111111111",
+ "firebase_url": "",
+ "project_id": "",
+ "storage_bucket": ""
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:111111111111:android:1111111111111111",
+ "android_client_info": {
+ "package_name": "ch.protonmail.android"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "DummyApiKey"
+ },
+ {
+ "current_key": "DummyApiKey"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": [
+ {
+ "client_id": "",
+ "client_type": 3
+ },
+ {
+ "client_id": "",
+ "client_type": 2,
+ "ios_info": {
+ "bundle_id": "ch.protonmail.protonmail"
+ }
+ }
+ ]
+ }
+ }
+ },
+ {
+ "client_info": {
+ "mobilesdk_app_id": "2:111111111111:android:1111111111111111",
+ "android_client_info": {
+ "package_name": "ch.protonmail.android.alpha"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "DummyApiKey"
+ },
+ {
+ "current_key": "DummyApiKey"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": [
+ {
+ "client_id": "",
+ "client_type": 3
+ },
+ {
+ "client_id": "",
+ "client_type": 2,
+ "ios_info": {
+ "bundle_id": "ch.protonmail.protonmail"
+ }
+ }
+ ]
+ }
+ }
+ },
+ {
+ "client_info": {
+ "mobilesdk_app_id": "3:111111111111:android:1111111111111111",
+ "android_client_info": {
+ "package_name": "ch.protonmail.android.beta"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "DummyApiKey"
+ },
+ {
+ "current_key": "DummyApiKey"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": [
+ {
+ "client_id": "",
+ "client_type": 3
+ },
+ {
+ "client_id": "",
+ "client_type": 2,
+ "ios_info": {
+ "bundle_id": "ch.protonmail.protonmail"
+ }
+ }
+ ]
+ }
+ }
+ },
+ {
+ "client_info": {
+ "mobilesdk_app_id": "4:111111111111:android:1111111111111111",
+ "android_client_info": {
+ "package_name": "ch.protonmail.android.dev"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "DummyApiKey"
+ },
+ {
+ "current_key": "DummyApiKey"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": [
+ {
+ "client_id": "",
+ "client_type": 3
+ },
+ {
+ "client_id": "",
+ "client_type": 2,
+ "ios_info": {
+ "bundle_id": "ch.protonmail.protonmail"
+ }
+ }
+ ]
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
\ No newline at end of file
diff --git a/coverage/build.gradle.kts b/coverage/build.gradle.kts
new file mode 100644
index 0000000000..63c0f941b2
--- /dev/null
+++ b/coverage/build.gradle.kts
@@ -0,0 +1,22 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ `kotlin-dsl`
+ id("me.proton.core.gradle-plugins.global-coverage")
+}
diff --git a/detekt-rules/build.gradle.kts b/detekt-rules/build.gradle.kts
new file mode 100644
index 0000000000..8bcc0b4999
--- /dev/null
+++ b/detekt-rules/build.gradle.kts
@@ -0,0 +1,28 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ kotlin("jvm")
+}
+
+dependencies {
+ compileOnly(libs.detekt.api)
+
+ testImplementation(libs.detekt.test)
+ testImplementation(libs.kotlin.test)
+}
diff --git a/detekt-rules/src/main/kotlin/me/proton/mail/detekt/MailRuleSetProvider.kt b/detekt-rules/src/main/kotlin/me/proton/mail/detekt/MailRuleSetProvider.kt
new file mode 100644
index 0000000000..5c0bb8985c
--- /dev/null
+++ b/detekt-rules/src/main/kotlin/me/proton/mail/detekt/MailRuleSetProvider.kt
@@ -0,0 +1,37 @@
+/*
+ * 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 .
+ */
+
+package me.proton.mail.detekt
+
+import io.gitlab.arturbosch.detekt.api.Config
+import io.gitlab.arturbosch.detekt.api.RuleSet
+import io.gitlab.arturbosch.detekt.api.RuleSetProvider
+
+class MailRuleSetProvider : RuleSetProvider {
+
+ override val ruleSetId: String = "MailRules"
+
+ override fun instance(config: Config): RuleSet {
+ return RuleSet(
+ ruleSetId,
+ listOf(
+ UseComposableActions()
+ )
+ )
+ }
+}
diff --git a/detekt-rules/src/main/kotlin/me/proton/mail/detekt/UseComposableActions.kt b/detekt-rules/src/main/kotlin/me/proton/mail/detekt/UseComposableActions.kt
new file mode 100644
index 0000000000..4e6670da7a
--- /dev/null
+++ b/detekt-rules/src/main/kotlin/me/proton/mail/detekt/UseComposableActions.kt
@@ -0,0 +1,88 @@
+/*
+ * 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 .
+ */
+
+package me.proton.mail.detekt
+
+import io.gitlab.arturbosch.detekt.api.CodeSmell
+import io.gitlab.arturbosch.detekt.api.Debt
+import io.gitlab.arturbosch.detekt.api.Entity
+import io.gitlab.arturbosch.detekt.api.Issue
+import io.gitlab.arturbosch.detekt.api.Rule
+import io.gitlab.arturbosch.detekt.api.Severity
+import me.proton.mail.detekt.UseComposableActions.Companion.Threshold
+import org.jetbrains.kotlin.psi.KtNamedFunction
+import org.jetbrains.kotlin.psi.KtParameter
+
+/**
+ * Reports functions annotated as `@Composable` with [Threshold] or more lambda parameters
+ * ```kotlin
+ * // compliant code
+ * @Composable
+ * fun SomeComposable(
+ * onBack: () -> Unit
+ * )
+ *
+ * // compliant code
+ * fun NotComposable(
+ * first: () -> Unit,
+ * second: () -> Unit,
+ * third: () -> Unit,
+ * fourth: () -> Unit,
+ * )
+ *
+ * // non-compliant code
+ * @Composable
+ * fun SomeComposable(
+ * onBack: () -> Unit,
+ * second: () -> Unit,
+ * third: () -> Unit,
+ * fourth: () -> Unit,
+ * )
+ * ```
+ */
+class UseComposableActions : Rule() {
+
+ override val issue = Issue(
+ javaClass.simpleName,
+ Severity.Maintainability,
+ Description,
+ Debt.FIVE_MINS
+ )
+
+ override fun visitNamedFunction(function: KtNamedFunction) {
+ super.visitNamedFunction(function)
+
+ val annotationNames = function.annotationEntries.map { annotation -> annotation.shortName.toString() }
+ if ("Composable" in annotationNames) {
+
+ val lambdaParametersCount = function.valueParameters.count(::isNotComposableLambda)
+ if (lambdaParametersCount >= Threshold) {
+ report(CodeSmell(issue, Entity.atName(function), Message))
+ }
+ }
+ }
+
+ private fun isLambda(parameter: KtParameter) = ") -> " in parameter.text
+ private fun isNotComposableLambda(parameter: KtParameter) = isLambda(parameter) && "@Composable" !in parameter.text
+ private companion object {
+
+ const val Description = "This rule reports a Composable functions with too many lambda parameters."
+ const val Message = "Too many lambda parameters: wrap them into an Actions class instead."
+ const val Threshold = 3
+ }
+}
diff --git a/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider
new file mode 100644
index 0000000000..11687c2f66
--- /dev/null
+++ b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider
@@ -0,0 +1 @@
+me.proton.mail.detekt.MailRuleSetProvider
diff --git a/detekt-rules/src/test/kotlin/me/proton/mail/detekt/UseComposableActionsTest.kt b/detekt-rules/src/test/kotlin/me/proton/mail/detekt/UseComposableActionsTest.kt
new file mode 100644
index 0000000000..bbe7b18f07
--- /dev/null
+++ b/detekt-rules/src/test/kotlin/me/proton/mail/detekt/UseComposableActionsTest.kt
@@ -0,0 +1,109 @@
+/*
+ * 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 .
+ */
+
+package me.proton.mail.detekt
+
+import io.gitlab.arturbosch.detekt.test.lint
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+internal class UseComposableActionsTest {
+
+ private val rule = UseComposableActions()
+
+ @Test
+ fun `reports Composable with lambda parameters above or same as the threshold`() {
+ // given
+ val expected = 1
+ val code = """
+ @Composable
+ fun SomeScreen(
+ first: () -> Unit,
+ second: () -> Unit,
+ third: () -> Unit,
+ fourth: () -> Unit
+ )
+ """.trimIndent()
+
+ // when
+ val findings = rule.lint(code)
+
+ // then
+ assertEquals(expected, findings.size)
+ }
+
+ @Test
+ fun `does not report Composable with lambda parameters below the threshold`() {
+ // given
+ val expected = 0
+ val code = """
+ @Composable
+ fun SomeScreen(
+ onBack: () -> Unit,
+ onNext: () -> Unit
+ )
+ """.trimIndent()
+
+ // when
+ val findings = rule.lint(code)
+
+ // then
+ assertEquals(expected, findings.size)
+ }
+
+ @Test
+ fun `does not report not Composable with lambda parameters above or same as the threshold`() {
+ // given
+ val expected = 0
+ val code = """
+ fun NotComposable(
+ first: () -> Unit,
+ second: () -> Unit,
+ third: () -> Unit,
+ fourth: () -> Unit
+ )
+ """.trimIndent()
+
+ // when
+ val findings = rule.lint(code)
+
+ // then
+ assertEquals(expected, findings.size)
+ }
+
+ @Test
+ fun `ignores lambda annotated as Composable`() {
+ // given
+ val expected = 0
+ val code = """
+ @Composable
+ fun SomeScreen(
+ first: () -> Unit,
+ second: @Composable () -> Unit,
+ third: @Composable () -> Unit,
+ fourth: @Composable () -> Unit
+ )
+ """.trimIndent()
+
+ // when
+ val findings = rule.lint(code)
+
+ // then
+ assertEquals(expected, findings.size)
+ }
+}
diff --git a/fastlane/Appfile b/fastlane/Appfile
new file mode 100644
index 0000000000..2e4bde4fb2
--- /dev/null
+++ b/fastlane/Appfile
@@ -0,0 +1,2 @@
+json_key_file("") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one
+package_name("com.example.myfirstapp") # e.g. com.krausefx.app
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
new file mode 100644
index 0000000000..b0a85f5e9d
--- /dev/null
+++ b/fastlane/Fastfile
@@ -0,0 +1,132 @@
+# This file contains the fastlane.tools configuration
+# You can find the documentation at https://docs.fastlane.tools
+#
+# For a list of all available actions, check out
+#
+# https://docs.fastlane.tools/actions
+#
+# For a list of all available plugins, check out
+#
+# https://docs.fastlane.tools/plugins/available-plugins
+#
+opt_out_usage
+
+default_platform(:android)
+
+platform :android do
+
+ desc "Execute static analysis"
+ lane :analyse do
+ gradle(task: "detekt")
+ end
+
+ desc "Assemble the devDebug APK"
+ lane :assembleDevDebug do
+ gradle(task: "assembleDevDebug", properties: { "enableFcmService" => false })
+ end
+
+ desc "Assemble the androidTest APK"
+ lane :assembleDevDebugAndroidTest do
+ gradle(task: "assembleDevDebugAndroidTest")
+ end
+
+ desc "Assemble the release APK for alpha flavor"
+ lane :assembleAlphaRelease do
+ bumpAppVersion
+ gradle(task: "assembleAlphaRelease")
+ end
+
+ desc "Assemble the release APK for prod flavor"
+ lane :assembleProdRelease do
+ bumpAppVersion
+ gradle(task: "assembleProdRelease")
+ end
+
+ desc "Bump the version code"
+ lane :bumpAppVersion do
+ sh("../scripts/release/bump_version.sh")
+ end
+
+ desc "Runs Unit Tests"
+ lane :unitTest do
+ gradle(tasks: ["testDevDebugUnitTest", "testDebugUnitTest", "testUtilsUnitTest"])
+ end
+
+ desc "Setup UI Tests assets"
+ lane :setupUiTestsAssets do
+ sh("../scripts/uitests/setup-core-assets.sh")
+ sh("../scripts/uitests/setup-mock-network-assets.sh setup-remote")
+ end
+
+ desc "Runs Proton Core Libraries Tests Suite on Firebase Test lab"
+ lane :coreLibsTest do
+ sh("../scripts/run_firebase_ui_tests.sh core-libs-test")
+ end
+
+ desc "Runs Smoke Tests Suite on Firebase Test lab"
+ lane :smokeTest do
+ sh("../scripts/run_firebase_ui_tests.sh smoke-test")
+ end
+
+ desc "Runs all UI tests on a wide set of devices on Firebase Test lab"
+ lane :fullRegressionTest do
+ sh("../scripts/run_firebase_ui_tests.sh full-regression-test")
+ end
+
+ desc "Generate test coverage report based on the last test run"
+ lane :coverageReport do
+ gradle(task: "-Pci --console=plain coberturaXmlReport globalLineCoverage :coverage:koverHtmlReport -x :coverage:jacocoToCobertura")
+ end
+
+ desc "Publish alpha build to firebase dev group"
+ lane :deployToFirebaseDevGroup do
+ firebase_app_distribution(
+ app: '1:75309174866:android:d354e9e5da9113aa78cf8b',
+ android_artifact_path: 'app/build/outputs/apk/alpha/release/app-alpha-release.apk',
+ # Service account is created on the CI from an env var each run (destroyed when finishing)
+ service_credentials_file: '/tmp/service-account.json',
+ groups: 'v6-dev-builds-testers',
+ release_notes_file: '/tmp/release_notes.txt'
+ )
+ end
+
+ desc "Publish nightly alpha build to firebase nightly group"
+ lane :deployToFirebaseNightlyGroup do
+ firebase_app_distribution(
+ app: '1:75309174866:android:d354e9e5da9113aa78cf8b',
+ android_artifact_path: 'app/build/outputs/apk/alpha/release/app-alpha-release.apk',
+ # Service account is created on the CI from an env var each run (destroyed when finishing)
+ service_credentials_file: '/tmp/service-account.json',
+ groups: 'v6-nightly-builds-testers',
+ release_notes_file: '/tmp/release_notes.txt'
+ )
+ end
+
+ desc "Publish alpha build to firebase alpha group"
+ lane :deployToFirebaseInternalAlphaGroup do
+ firebase_app_distribution(
+ app: '1:75309174866:android:d354e9e5da9113aa78cf8b',
+ android_artifact_path: 'app/build/outputs/apk/alpha/release/app-alpha-release.apk',
+ # Service account is created on the CI from an env var each run (destroyed when finishing)
+ service_credentials_file: '/tmp/service-account.json',
+ groups: 'v6-internal-alpha-testers',
+ release_notes_file: '/tmp/release_notes.txt'
+ )
+ end
+
+ desc "Tag commit with release version name and code"
+ lane :tagRelease do
+ sh("../scripts/release/tag_release.sh")
+ end
+
+ desc "Deploy to Play Store (Internal Track)"
+ lane :deployToPlayStoreInternal do
+ upload_to_play_store(
+ package_name: "ch.protonmail.android",
+ track: "internal",
+ apk: "./app/build/outputs/apk/prod/release/app-prod-release.apk",
+ json_key: "/tmp/play_store_service_account.json"
+ )
+ end
+end
+
diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile
new file mode 100644
index 0000000000..b18539bc9b
--- /dev/null
+++ b/fastlane/Pluginfile
@@ -0,0 +1,5 @@
+# Autogenerated by fastlane
+#
+# Ensure this file is checked in to source control!
+
+gem 'fastlane-plugin-firebase_app_distribution'
diff --git a/firebase-device-config.yml b/firebase-device-config.yml
new file mode 100644
index 0000000000..350bc92378
--- /dev/null
+++ b/firebase-device-config.yml
@@ -0,0 +1,18 @@
+fullTest:
+ device:
+ - model: MediumPhone.arm
+ version: 34
+ - model: Pixel2.arm
+ version: 33
+ - model: Pixel2.arm
+ version: 29
+ - model: Pixel2.arm
+ version: 28
+smokeTest:
+ device:
+ - model: MediumPhone.arm
+ version: 34
+ - model: Pixel2.arm
+ version: 30
+ - model: Pixel2.arm
+ version: 28
diff --git a/gradle.properties b/gradle.properties
index 98bed167dc..a9d15a1bdd 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,21 +1,43 @@
+#
+# 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 .
+#
+
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
+org.gradle.caching=true
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
-# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. More details, visit
-# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
-# org.gradle.parallel=true
+org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
+org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
-# Automatically convert third-party libraries to use AndroidX
-android.enableJetifier=true
+android.experimental.enableArtProfiles=true
+android.nonTransitiveRClass=false
+android.nonFinalResIds=false
+# https://r8.googlesource.com/r8/+/refs/heads/master/compatibility-faq.md#r8-full-mode
+android.enableR8.fullMode=true
# Kotlin code style for this project: "official" or "obsolete":
-kotlin.code.style=official
\ No newline at end of file
+kotlin.code.style=official
+# IncludeGit Gradle Plugin: override include with local.
+#auto.include.git.dirs=../
+#local.git.proton-libs=../proton-libs
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000000..b6e285edcb
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,413 @@
+[versions]
+android-gradle-plugin = "8.7.3"
+google-services-plugin = "4.4.2"
+proton-core-plugin = "1.3.0"
+sentry-gradle-plugin = "4.14.1"
+
+accompanist = "0.30.1"
+desugar-jdk-libs = "2.1.3"
+androidx-activity = "1.9.3"
+androidx-annotation = "1.9.1"
+androidx-appcompat = "1.7.0"
+androidx-biometrics = "1.2.0-alpha05"
+androidx-compose = "1.7.5"
+androidx-core = "1.13.1" # 1.15.0 requires compileSdk = 35
+androidx-compose-tracing = "1.7.5"
+androidx-constraintlayout-compose = "1.0.1" # See MAILANDR-2396
+androidx-customview = "1.2.0-alpha02"
+androidx-customview-poolingcontainer = "1.0.0"
+androidx-datastore = "1.1.1"
+androidx-hilt = "1.2.0"
+androidx-lifecycle = "2.8.7"
+androidx-material3 = "1.3.1"
+androidx-navigation = "2.8.4"
+androidx-paging = "3.3.4"
+androidx-paging-compose = "3.3.4"
+androidx-perfetto = "1.0.0"
+androidx-profile-installer = "1.4.1"
+androidx-room = "2.7.0-alpha10"
+androidx-splashscreen = "1.0.1"
+androidx-test-androidjunit = "1.2.1"
+androidx-test-core = "1.6.1"
+androidx-test-macrobenchmark = "1.3.3"
+androidx-test-monitor = "1.7.2"
+androidx-test-runner = "1.6.2"
+androidx-test-rules = "1.6.1"
+androidx-test-orchestrator = "1.5.1"
+androidx-test-espresso = "3.6.1"
+androidx-test-uiautomator = "2.3.0"
+androidx-tracing = "1.2.0"
+androidx-webkit = "1.12.1"
+androidx-work = "2.9.1" # 2.10.0 requires compileSdk = 35
+arrow-core = "1.2.4"
+cash-turbine = "1.0.0"
+coil = "2.4.0"
+dagger = "2.49"
+detekt = "1.23.5"
+firebase-bom = "33.6.0"
+guava = "33.2.1-jre"
+timber = "5.0.1"
+javax-inject = "1"
+junit = "4.13.2"
+jsoup = "1.16.1"
+ksp-symbol-processing-api = "2.0.21-1.0.27"
+kotlin = "2.0.21"
+kotlin-compile-testing = "0.6.0"
+kotlinx-coroutines = "1.8.0"
+kotlinx-immutable-collections = "0.3.5"
+kotlinx-serialization = "1.6.3"
+material = "1.12.0"
+mockk = "1.13.13"
+play-review = "2.0.2"
+proton-core = "32.0.0"
+kotlinpoet-ksp = "2.0.0"
+leakcanary = "2.12"
+okhttp = "4.12.0"
+retrofit = "2.11.0"
+sentry = "7.22.5"
+
+[plugins]
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp-symbol-processing-api" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+proton-core-detekt = { id = "me.proton.core.gradle-plugins.detekt", version.ref = "proton-core-plugin" }
+proton-core-coverage = { id = "me.proton.core.gradle-plugins.coverage", version.ref = "proton-core-plugin" }
+proton-core-coverage-config = { id = "me.proton.core.gradle-plugins.coverage-config", version.ref = "proton-core-plugin" }
+proton-core-global-coverage = { id = "me.proton.core.gradle-plugins.global-coverage", version.ref = "proton-core-plugin" }
+
+[libraries]
+# Classpath dependencies
+android-tools-build = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" }
+kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
+hilt-android-gradle = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "dagger" }
+google-services = { module = "com.google.gms:google-services", version.ref = "google-services-plugin" }
+sentry-gradle = { module = "io.sentry:sentry-android-gradle-plugin", version.ref = "sentry-gradle-plugin" }
+
+# Standard dependencies
+accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" }
+accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
+android-tools-desugarJdkLibs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar-jdk-libs" }
+android-material = { module = "com.google.android.material:material", version.ref = "material" }
+androidx-activity-ktx = { module = "androidx.activity:activity", version.ref = "androidx-activity" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
+androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
+androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" }
+androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout-compose" }
+androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose" }
+androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout", version.ref = "androidx-compose" }
+androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose" }
+androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-material3" }
+androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "androidx-compose" }
+androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata", version.ref = "androidx-compose" }
+androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose" }
+androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose" }
+androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose" }
+androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test", version.ref = "androidx-compose" }
+androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-compose" }
+androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose" }
+androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
+androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
+androidx-customview = { module = "androidx.customview:customview", version.ref = "androidx-customview" }
+androidx-customview-poolingcontainer = { module = "androidx.customview:customview-poolingcontainer", version.ref = "androidx-customview-poolingcontainer" }
+androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" }
+androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidx-hilt" }
+androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" }
+androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidx-hilt" }
+androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" }
+androidx-biometrics = { module = "androidx.biometric:biometric-ktx", version.ref = "androidx-biometrics" }
+androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
+androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" }
+androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "androidx-paging" }
+androidx-paging-testing = { module = "androidx.paging:paging-testing", version.ref = "androidx-paging-compose" }
+androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging-compose" }
+androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "androidx-profile-installer" }
+androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" }
+androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
+androidx-test-androidjunit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-androidjunit" }
+androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" }
+androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
+androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" }
+androidx-test-espresso-web = { module = "androidx.test.espresso:espresso-web", version.ref = "androidx-test-espresso" }
+androidx-test-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "androidx-test-espresso" }
+androidx-test-macrobenchmark = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-test-macrobenchmark" }
+androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" }
+androidx-test-monitor = { module = "androidx.test:monitor", version.ref = "androidx-test-monitor" }
+androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
+androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" }
+androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-test-uiautomator" }
+androidx-tracing = { module = "androidx.tracing:tracing", version.ref = "androidx-tracing" }
+androidx-tracing-compose-runtime = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-compose-tracing" }
+androidx-tracing-perfetto = { module = "androidx.tracing:tracing-perfetto", version.ref = "androidx-perfetto" }
+androidx-tracing-perfetto-binary = { module = "androidx.tracing:tracing-perfetto-binary", version.ref = "androidx-perfetto" }
+androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" }
+androidx-work-runtimeKtx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
+arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow-core" }
+cash-turbine = { module = "app.cash.turbine:turbine", version.ref = "cash-turbine" }
+coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
+dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagger" }
+dagger-hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "dagger" }
+dagger-hilt-core = { module = "com.google.dagger:hilt-core", version.ref = "dagger" }
+dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "dagger" }
+detekt-api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" }
+detekt-test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" }
+firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
+firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" }
+javax-inject = { module = "javax.inject:javax.inject", version.ref = "javax-inject" }
+jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
+google-play-review = { module = "com.google.android.play:review", version.ref = "play-review" }
+google-play-reviewKtx = { module = "com.google.android.play:review-ktx", version.ref = "play-review" }
+guava = { module = "com.google.guava:guava", version.ref = "guava" }
+junit = { module = "junit:junit", version.ref = "junit" }
+kotlinCompileTesting = { module = "dev.zacsweers.kctfork:ksp", version.ref = "kotlin-compile-testing" }
+kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
+kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
+kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
+kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
+kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
+ksp-symbolProcessingApi = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp-symbol-processing-api" }
+kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinpoet-ksp" }
+leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" }
+timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
+kotlinx-immutableCollections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-immutable-collections" }
+mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
+mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
+mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
+okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
+okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
+retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
+sentry-compose = { module = "io.sentry:sentry-compose", version.ref = "sentry" }
+sentry = { module = "io.sentry:sentry", version.ref = "sentry" }
+plumber = { module = "com.squareup.leakcanary:plumber-android", version.ref = "leakcanary" }
+
+# Proton Core libraries
+proton-core-account = { module = "me.proton.core:account", version.ref = "proton-core" }
+proton-core-account-data = { module = "me.proton.core:account-data", version.ref = "proton-core" }
+proton-core-accountManager = { module = "me.proton.core:account-manager", version.ref = "proton-core" }
+proton-core-accountManagerPresentationCompose = { module = "me.proton.core:account-manager-presentation-compose", version.ref = "proton-core" }
+proton-core-accountRecovery = { module = "me.proton.core:account-recovery", version.ref = "proton-core" }
+proton-core-accountRecoveryTest = { module = "me.proton.core:account-recovery-test", version.ref = "proton-core" }
+proton-core-auth = { module = "me.proton.core:auth", version.ref = "proton-core" }
+proton-core-authTest = { module = "me.proton.core:auth-test", version.ref = "proton-core" }
+proton-core-authFido = { module = "me.proton.core:auth-fido", version.ref = "proton-core" }
+proton-core-biometric = { module = "me.proton.core:biometric", version.ref = "proton-core" }
+proton-core-challenge = { module = "me.proton.core:challenge", version.ref = "proton-core" }
+proton-core-contact = { module = "me.proton.core:contact", version.ref = "proton-core" }
+proton-core-contact-domain = { module = "me.proton.core:contact-domain", version.ref = "proton-core" }
+proton-core-configuration-data = { module = "me.proton.core:configuration-data", version.ref = "proton-core" }
+proton-core-configuration-dagger-static = { module = "me.proton.core:configuration-dagger-staticdefaults", version.ref = "proton-core" }
+proton-core-configuration-dagger-contentProvider = { module = "me.proton.core:configuration-dagger-content-resolver", version.ref = "proton-core" }
+proton-core-country = { module = "me.proton.core:country", version.ref = "proton-core" }
+proton-core-crypto = { module = "me.proton.core:crypto", version.ref = "proton-core" }
+proton-core-cryptoValidator = { module = "me.proton.core:crypto-validator", version.ref = "proton-core" }
+proton-core-data = { module = "me.proton.core:data", version.ref = "proton-core" }
+proton-core-dataRoom = { module = "me.proton.core:data-room", version.ref = "proton-core" }
+proton-core-deviceMigration = { module = "me.proton.core:device-migration", version.ref = "proton-core" }
+proton-core-domain = { module = "me.proton.core:domain", version.ref = "proton-core" }
+proton-core-eventManager = { module = "me.proton.core:event-manager", version.ref = "proton-core" }
+proton-core-featureFlag = { module = "me.proton.core:feature-flag", version.ref = "proton-core" }
+proton-core-humanVerification = { module = "me.proton.core:human-verification", version.ref = "proton-core" }
+proton-core-key = { module = "me.proton.core:key", version.ref = "proton-core" }
+proton-core-keyTransparency = { module = "me.proton.core:key-transparency", version.ref = "proton-core" }
+proton-core-label = { module = "me.proton.core:label", version.ref = "proton-core" }
+proton-core-label-data = { module = "me.proton.core:label-data", version.ref = "proton-core" }
+proton-core-label-domain = { module = "me.proton.core:label-domain", version.ref = "proton-core" }
+proton-core-mailSendPreferences = { module = "me.proton.core:mail-send-preferences", version.ref = "proton-core" }
+proton-core-mailSettings = { module = "me.proton.core:mail-settings", version.ref = "proton-core" }
+proton-core-network = { module = "me.proton.core:network", version.ref = "proton-core" }
+proton-core-notification = { module = "me.proton.core:notification", version.ref = "proton-core" }
+proton-core-observability = { module = "me.proton.core:observability", version.ref = "proton-core" }
+proton-core-payment = { module = "me.proton.core:payment", version.ref = "proton-core" }
+proton-core-paymentIap = { module = "me.proton.core:payment-iap", version.ref = "proton-core" }
+proton-core-plan = { module = "me.proton.core:plan", version.ref = "proton-core" }
+proton-core-plan-presentationCompose = { module = "me.proton.core:plan-presentation-compose", version.ref = "proton-core" }
+proton-core-planTest = { module = "me.proton.core:plan-test", version.ref = "proton-core" }
+proton-core-presentation = { module = "me.proton.core:presentation", version.ref = "proton-core" }
+proton-core-presentationCompose = { module = "me.proton.core:presentation-compose", version.ref = "proton-core" }
+proton-core-proguardRules = { module = "me.proton.core:proguard-rules", version.ref = "proton-core" }
+proton-core-push = { module = "me.proton.core:push", version.ref = "proton-core" }
+proton-core-report = { module = "me.proton.core:report", version.ref = "proton-core" }
+proton-core-reportTest = { module = "me.proton.core:report-test", version.ref = "proton-core" }
+proton-core-telemetry = { module = "me.proton.core:telemetry", version.ref = "proton-core" }
+proton-core-user = { module = "me.proton.core:user", version.ref = "proton-core" }
+proton-core-userRecovery = { module = "me.proton.core:user-recovery", version.ref = "proton-core" }
+proton-core-userRecoveryTest = { module = "me.proton.core:user-recovery-test", version.ref = "proton-core" }
+proton-core-userSettings = { module = "me.proton.core:user-settings", version.ref = "proton-core" }
+proton-core-utilAndroidDagger = { module = "me.proton.core:util-android-dagger", version.ref = "proton-core" }
+proton-core-utilAndroidSentry = { module = "me.proton.core:util-android-sentry", version.ref = "proton-core" }
+proton-core-utilAndroidStrictMode = { module = "me.proton.core:util-android-strict-mode", version.ref = "proton-core" }
+proton-core-testAndroid = { module = "me.proton.core:test-android", version.ref = "proton-core" }
+proton-core-testKotlin = { module = "me.proton.core:test-kotlin", version.ref = "proton-core" }
+proton-core-testQuark = { module = "me.proton.core:test-quark", version.ref = "proton-core" }
+proton-core-testRule = { module = "me.proton.core:test-rule", version.ref = "proton-core" }
+proton-core-testAndroidInstrumented = { module = "me.proton.core:test-android-instrumented", version.ref = "proton-core" }
+
+[bundles]
+appLibs = [
+ "androidx-activity-ktx",
+ "androidx-appcompat",
+ "androidx-biometrics",
+ "androidx-compose-runtime-livedata",
+ "androidx-core-splashscreen",
+ "androidx-hilt-navigation-compose",
+ "dagger-hilt-android",
+ "dagger-hilt-compiler",
+ "androidx-hilt-work",
+ "androidx-lifecycle-process",
+ "androidx-navigation-compose",
+ "androidx-paging-compose",
+ "androidx-paging-runtime",
+ "androidx-profileinstaller",
+ "androidx-room-ktx",
+ "androidx-work-runtimeKtx",
+ "android-material",
+ "arrow-core",
+ "timber",
+ "google-play-review",
+ "google-play-reviewKtx",
+ "kotlinx-immutableCollections",
+ "okhttp",
+ "plumber",
+ "sentry",
+ "sentry-compose",
+ "proton-core-proguardRules",
+ "proton-core-account",
+ "proton-core-accountManager",
+ "proton-core-accountRecovery",
+ "proton-core-auth",
+ "proton-core-authFido",
+ "proton-core-biometric",
+ "proton-core-challenge",
+ "proton-core-contact",
+ "proton-core-country",
+ "proton-core-crypto",
+ "proton-core-cryptoValidator",
+ "proton-core-data",
+ "proton-core-dataRoom",
+ "proton-core-deviceMigration",
+ "proton-core-domain",
+ "proton-core-eventManager",
+ "proton-core-featureFlag",
+ "proton-core-humanVerification",
+ "proton-core-key",
+ "proton-core-keyTransparency",
+ "proton-core-label",
+ "proton-core-mailSettings",
+ "proton-core-network",
+ "proton-core-notification",
+ "proton-core-observability",
+ "proton-core-payment",
+ "proton-core-paymentIap",
+ "proton-core-plan",
+ "proton-core-presentation",
+ "proton-core-presentationCompose",
+ "proton-core-push",
+ "proton-core-report",
+ "proton-core-telemetry",
+ "proton-core-user",
+ "proton-core-userRecovery",
+ "proton-core-userSettings",
+ "proton-core-configuration-data",
+ "proton-core-utilAndroidDagger",
+ "proton-core-utilAndroidSentry",
+ "proton-core-utilAndroidStrictMode",
+]
+
+compose = [
+ "androidx-activity-compose",
+ "androidx-compose-foundation",
+ "androidx-compose-foundation-layout",
+ "androidx-compose-material",
+ "androidx-compose-material3",
+ "androidx-compose-runtime",
+ "androidx-compose-runtime-livedata",
+ "androidx-compose-ui",
+ "androidx-compose-ui-tooling-preview",
+ "androidx-constraintlayout-compose"
+]
+
+compose-debug = [
+ "androidx-compose-ui-test-manifest",
+ "androidx-compose-ui-tooling",
+ "androidx-customview",
+ "androidx-customview-poolingcontainer"
+]
+
+module-presentation = [
+ "androidx-annotation",
+ "androidx-compose-material3",
+ "androidx-compose-ui-tooling-preview",
+ "arrow-core",
+ "dagger-hilt-android",
+ "javax-inject",
+ "kotlinx-immutableCollections",
+ "proton-core-domain",
+ "proton-core-presentation",
+ "proton-core-presentationCompose",
+ "timber"
+]
+
+module-data = [
+ "androidx-datastore-preferences",
+ "androidx-room-ktx",
+ "androidx-work-runtimeKtx",
+ "arrow-core",
+ "dagger-hilt-android",
+ "timber",
+ "javax-inject",
+ "kotlin-serialization-json",
+ "okhttp",
+ "retrofit"
+]
+
+module-domain = [
+ "arrow-core",
+ "dagger-hilt-android",
+ "timber",
+ "javax-inject",
+ "kotlin-coroutines-core"
+]
+
+app-annotationProcessors = [
+ "androidx-room-compiler",
+ "androidx-hilt-compiler",
+ "dagger-hilt-compiler"
+]
+
+app-debug = [
+ "leakcanary-android"
+]
+
+test = [
+ "cash-turbine",
+ "guava",
+ "junit",
+ "kotlin-test",
+ "kotlin-test-junit",
+ "kotlin-coroutines-test",
+ "mockk",
+ "proton-core-testAndroid",
+ "proton-core-testKotlin",
+ "proton-core-testQuark"
+]
+
+test-androidTest = [
+ "androidx-compose-ui-test",
+ "androidx-compose-ui-test-junit4",
+ "androidx-datastore-preferences",
+ "androidx-test-core",
+ "androidx-test-core-ktx",
+ "androidx-test-espresso-core",
+ "androidx-test-espresso-web",
+ "androidx-test-espresso-intents",
+ "androidx-test-monitor",
+ "androidx-test-rules",
+ "androidx-test-runner",
+ "androidx-test-uiautomator",
+ "cash-turbine",
+ "dagger-hilt-android-testing",
+ "kotlin-test",
+ "kotlin-test-junit",
+ "mockk-android",
+ "proton-core-testAndroidInstrumented",
+ "proton-core-testQuark"
+]
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index e708b1c023..2c3521197d 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 9c96e40415..09523c0e54 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,7 @@
-#Tue Oct 26 13:03:32 CEST 2021
distributionBase=GRADLE_USER_HOME
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
distributionPath=wrapper/dists
-zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index 4f906e0c81..f5feea6d6b 100755
--- a/gradlew
+++ b/gradlew
@@ -1,7 +1,7 @@
-#!/usr/bin/env sh
+#!/bin/sh
#
-# Copyright 2015 the original author or authors.
+# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -15,69 +15,104 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
+# SPDX-License-Identifier: Apache-2.0
+#
##############################################################################
-##
-## Gradle start up script for UN*X
-##
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
##############################################################################
# Attempt to set APP_HOME
+
# Resolve links: $0 may be a link
-PRG="$0"
-# Need this for relative symlinks.
-while [ -h "$PRG" ] ; do
- ls=`ls -ld "$PRG"`
- link=`expr "$ls" : '.*-> \(.*\)$'`
- if expr "$link" : '/.*' > /dev/null; then
- PRG="$link"
- else
- PRG=`dirname "$PRG"`"/$link"
- fi
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
done
-SAVED="`pwd`"
-cd "`dirname \"$PRG\"`/" >/dev/null
-APP_HOME="`pwd -P`"
-cd "$SAVED" >/dev/null
-APP_NAME="Gradle"
-APP_BASE_NAME=`basename "$0"`
-
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD="maximum"
+MAX_FD=maximum
warn () {
echo "$*"
-}
+} >&2
die () {
echo
echo "$*"
echo
exit 1
-}
+} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
-case "`uname`" in
- CYGWIN* )
- cygwin=true
- ;;
- Darwin* )
- darwin=true
- ;;
- MINGW* )
- msys=true
- ;;
- NONSTOP* )
- nonstop=true
- ;;
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
@@ -87,9 +122,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
- JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACMD=$JAVA_HOME/jre/sh/java
else
- JAVACMD="$JAVA_HOME/bin/java"
+ JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -98,88 +133,120 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
- JAVACMD="java"
- which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
+ fi
fi
# Increase the maximum file descriptors if we can.
-if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
- MAX_FD_LIMIT=`ulimit -H -n`
- if [ $? -eq 0 ] ; then
- if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
- MAX_FD="$MAX_FD_LIMIT"
- fi
- ulimit -n $MAX_FD
- if [ $? -ne 0 ] ; then
- warn "Could not set maximum file descriptor limit: $MAX_FD"
- fi
- else
- warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
- fi
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
fi
-# For Darwin, add options to specify how the application appears in the dock
-if $darwin; then
- GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
-fi
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
-if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
- APP_HOME=`cygpath --path --mixed "$APP_HOME"`
- CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
-
- JAVACMD=`cygpath --unix "$JAVACMD"`
-
- # We build the pattern for arguments to be converted via cygpath
- ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
- SEP=""
- for dir in $ROOTDIRSRAW ; do
- ROOTDIRS="$ROOTDIRS$SEP$dir"
- SEP="|"
- done
- OURCYGPATTERN="(^($ROOTDIRS))"
- # Add a user-defined pattern to the cygpath arguments
- if [ "$GRADLE_CYGPATTERN" != "" ] ; then
- OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
- fi
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
# Now convert the arguments - kludge to limit ourselves to /bin/sh
- i=0
- for arg in "$@" ; do
- CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
- CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
-
- if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
- eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
- else
- eval `echo args$i`="\"$arg\""
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
fi
- i=`expr $i + 1`
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
done
- case $i in
- 0) set -- ;;
- 1) set -- "$args0" ;;
- 2) set -- "$args0" "$args1" ;;
- 3) set -- "$args0" "$args1" "$args2" ;;
- 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
- 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
- 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
- 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
- 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
- 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
- esac
fi
-# Escape application args
-save () {
- for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
- echo " "
-}
-APP_ARGS=`save "$@"`
-# Collect all arguments for the java command, following the shell quoting and substitution rules
-eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
index ac1b06f938..9b42019c79 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -13,8 +13,10 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
-@if "%DEBUG%" == "" @echo off
+@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@@ -25,7 +27,8 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto execute
+if %ERRORLEVEL% equ 0 goto execute
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
@@ -56,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
@@ -75,13 +78,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end
@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
+if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
diff --git a/mail-bugreport/build.gradle.kts b/mail-bugreport/build.gradle.kts
new file mode 100644
index 0000000000..704f1a6c47
--- /dev/null
+++ b/mail-bugreport/build.gradle.kts
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ id("com.android.library")
+ kotlin("android")
+}
+
+android {
+ namespace = "ch.protonmail.android.mailbugreport"
+ compileSdk = Config.compileSdk
+
+ defaultConfig {
+ minSdk = Config.minSdk
+ lint.targetSdk = Config.targetSdk
+ }
+}
+
+dependencies {
+ api(project(":mail-bugreport:dagger"))
+ api(project(":mail-bugreport:data"))
+ api(project(":mail-bugreport:domain"))
+ api(project(":mail-bugreport:presentation"))
+}
diff --git a/mail-bugreport/dagger/build.gradle.kts b/mail-bugreport/dagger/build.gradle.kts
new file mode 100644
index 0000000000..071a9131cd
--- /dev/null
+++ b/mail-bugreport/dagger/build.gradle.kts
@@ -0,0 +1,54 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ kotlin("kapt")
+ id("dagger.hilt.android.plugin")
+}
+
+android {
+ namespace = "ch.protonmail.android.mailbugreport.dagger"
+ compileSdk = Config.compileSdk
+
+ defaultConfig {
+ minSdk = Config.minSdk
+ lint.targetSdk = Config.targetSdk
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+}
+
+dependencies {
+ kapt(libs.bundles.app.annotationProcessors)
+ implementation(libs.dagger.hilt.android)
+ implementation(libs.proton.core.report)
+ implementation(libs.kotlin.coroutines.core)
+
+ implementation(project(":mail-bugreport:data"))
+ implementation(project(":mail-bugreport:domain"))
+ implementation(project(":mail-common:domain"))
+}
diff --git a/mail-bugreport/dagger/src/main/kotlin/ch/protonmail/android/mailbugreport/MailReportModule.kt b/mail-bugreport/dagger/src/main/kotlin/ch/protonmail/android/mailbugreport/MailReportModule.kt
new file mode 100644
index 0000000000..d0bc590ad4
--- /dev/null
+++ b/mail-bugreport/dagger/src/main/kotlin/ch/protonmail/android/mailbugreport/MailReportModule.kt
@@ -0,0 +1,79 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport
+
+import ch.protonmail.android.mailbugreport.data.LogsFileHandlerImpl
+import ch.protonmail.android.mailbugreport.data.provider.BugReportLogProviderImpl
+import ch.protonmail.android.mailbugreport.data.provider.LogcatProviderImpl
+import ch.protonmail.android.mailbugreport.domain.LogsExportFeatureSetting
+import ch.protonmail.android.mailbugreport.domain.LogsFileHandler
+import ch.protonmail.android.mailbugreport.domain.annotations.LogsExportFeatureSettingValue
+import ch.protonmail.android.mailbugreport.domain.featureflags.IsLogsExportingFeatureEnabled
+import ch.protonmail.android.mailbugreport.domain.featureflags.IsLogsExportingInternalFeatureEnabled
+import ch.protonmail.android.mailbugreport.domain.provider.LogcatProvider
+import ch.protonmail.android.mailcommon.domain.AppInformation
+import ch.protonmail.android.mailcommon.domain.isDevOrAlphaFlavor
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.Reusable
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import me.proton.core.report.domain.provider.BugReportLogProvider
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object MailReportModule {
+
+ @Provides
+ @Singleton
+ @LogsExportFeatureSettingValue
+ fun provideLogsExporting(
+ isEnabled: IsLogsExportingFeatureEnabled,
+ isInternalEnabled: IsLogsExportingInternalFeatureEnabled,
+ appInformation: AppInformation
+ ): LogsExportFeatureSetting {
+ return if (appInformation.isDevOrAlphaFlavor()) {
+ LogsExportFeatureSetting(enabled = true, internalEnabled = true)
+ } else
+ LogsExportFeatureSetting(enabled = isEnabled(), internalEnabled = isInternalEnabled())
+ }
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ internal interface BindsModule {
+
+ @Binds
+ @Reusable
+ fun provideLogcatProvider(impl: LogcatProviderImpl): LogcatProvider
+
+ @Binds
+ @Singleton
+ fun provideLogsFileHandler(impl: LogsFileHandlerImpl): LogsFileHandler
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface CoreLogModule {
+
+ @Binds
+ fun provideBugReportLogProvider(impl: BugReportLogProviderImpl): BugReportLogProvider
+}
diff --git a/mail-bugreport/data/build.gradle.kts b/mail-bugreport/data/build.gradle.kts
new file mode 100644
index 0000000000..9f99fc9991
--- /dev/null
+++ b/mail-bugreport/data/build.gradle.kts
@@ -0,0 +1,56 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ kotlin("kapt")
+ kotlin("plugin.serialization")
+}
+
+android {
+ namespace = "ch.protonmail.android.mailbugreport.data"
+ compileSdk = Config.compileSdk
+
+ defaultConfig {
+ minSdk = Config.minSdk
+ lint.targetSdk = Config.targetSdk
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+}
+
+dependencies {
+ kapt(libs.bundles.app.annotationProcessors)
+ implementation(libs.bundles.module.data)
+
+ implementation(libs.timber)
+ implementation(libs.proton.core.report)
+
+ implementation(project(":mail-common:domain"))
+ implementation(project(":mail-bugreport:domain"))
+
+ testImplementation(libs.bundles.test)
+}
diff --git a/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/FileLoggingTree.kt b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/FileLoggingTree.kt
new file mode 100644
index 0000000000..6f19d173e8
--- /dev/null
+++ b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/FileLoggingTree.kt
@@ -0,0 +1,83 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.data
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import android.util.Log
+import ch.protonmail.android.mailbugreport.domain.LogsFileHandler
+import timber.log.Timber
+
+class FileLoggingTree(private val logsFileHandler: LogsFileHandler) : Timber.Tree() {
+
+ init {
+ initNewSession()
+ }
+
+ override fun log(
+ priority: Int,
+ tag: String?,
+ message: String,
+ t: Throwable?
+ ) {
+ if (tag in ExcludedTags) return
+ val logMessage = createLogMessage(priority, tag, message)
+ logsFileHandler.writeLog(logMessage)
+ }
+
+ private fun initNewSession() {
+ Timber.tag("FileLoggingTree").i(
+ """
+ |
+ |---------- New Session ----------
+ |
+ """.trimMargin()
+ )
+ }
+
+ private fun createLogMessage(
+ priority: Int,
+ tag: String?,
+ message: String
+ ): String {
+ val priorityTag = priorityChar(priority)
+ val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(Date())
+ return "$timestamp $priorityTag/$tag: $message"
+ }
+
+ private fun priorityChar(priority: Int): Char {
+ return when (priority) {
+ Log.VERBOSE -> 'V'
+ Log.DEBUG -> 'D'
+ Log.INFO -> 'I'
+ Log.WARN -> 'W'
+ Log.ERROR -> 'E'
+ Log.ASSERT -> 'A'
+ else -> '?'
+ }
+ }
+
+ private companion object {
+
+ val ExcludedTags = listOf(
+ "core.network"
+ )
+ }
+}
diff --git a/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/LogsFileHandlerImpl.kt b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/LogsFileHandlerImpl.kt
new file mode 100644
index 0000000000..426a1c299c
--- /dev/null
+++ b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/LogsFileHandlerImpl.kt
@@ -0,0 +1,119 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.data
+
+import java.io.File
+import java.io.FileWriter
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import android.content.Context
+import ch.protonmail.android.mailbugreport.domain.LogsFileHandler
+import ch.protonmail.android.mailcommon.domain.coroutines.IODispatcher
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import kotlin.coroutines.CoroutineContext
+
+class LogsFileHandlerImpl @Inject constructor(
+ @ApplicationContext private val context: Context,
+ @IODispatcher private val coroutineDispatcher: CoroutineDispatcher
+) : LogsFileHandler, CoroutineScope {
+
+ override val coroutineContext: CoroutineContext = coroutineDispatcher + SupervisorJob()
+
+ private val logDir by lazy {
+ File(context.cacheDir, LogsDirName)
+ .apply { mkdirs() }
+ }
+
+ private var currentLogFile: File? = null
+ private var fileWriter: FileWriter? = null
+
+ override fun getParentPath(): File = logDir
+
+ override fun getLastLogFile(): File? = currentLogFile
+
+ override fun writeLog(message: String) = launch {
+ runCatching {
+ (fileWriter ?: prepareFileWriter()).let { writer ->
+ writer.appendLine(message)
+ writer.flush()
+ }
+
+ currentLogFile?.takeIf { it.length() > MaxFileSizeBytes }?.let {
+ rotateLogFiles()
+ }
+ }.onFailure { it.printStackTrace() }
+ }.let {}
+
+ private fun prepareFileWriter(): FileWriter {
+ val files = logDir.listFiles().orEmpty().sortedBy { it.lastModified() }
+
+ currentLogFile = when {
+ files.isEmpty() -> createNewFile()
+ files.last().length() > MaxFileSizeBytes -> createNewFile()
+ else -> files.last()
+ }
+
+ return FileWriter(currentLogFile, true).also { fileWriter = it }
+ }
+
+ private fun rotateLogFiles() {
+ val existingFiles = logDir.listFiles().orEmpty()
+ .sortedBy { it.lastModified() }
+
+ if (existingFiles.size >= MaxLogFiles) {
+ existingFiles
+ .take(existingFiles.size - MaxLogFiles + 1)
+ .forEach { it.delete() }
+ }
+
+ prepareFileWriter()
+ }
+
+ private fun createNewFile(): File {
+ val date = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()).format(Date())
+ val newFile = File(logDir, "log-$date.txt")
+
+ runCatching {
+ newFile.createNewFile()
+ }.onFailure { it.printStackTrace() }
+
+ return newFile
+ }
+
+ override fun close() {
+ cancel()
+ runCatching {
+ fileWriter?.close()
+ }.onFailure { it.printStackTrace() }
+ }
+
+ companion object {
+
+ private const val LogsDirName = "logs"
+ private const val MaxFileSizeBytes = 2 * 1024 * 1024 // 2 MB
+ private const val MaxLogFiles = 3
+ }
+}
diff --git a/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/provider/BugReportProviderImpl.kt b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/provider/BugReportProviderImpl.kt
new file mode 100644
index 0000000000..8a7ee66c20
--- /dev/null
+++ b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/provider/BugReportProviderImpl.kt
@@ -0,0 +1,33 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.data.provider
+
+import java.io.File
+import ch.protonmail.android.mailbugreport.domain.usecase.GetAggregatedEventsZipFile
+import me.proton.core.report.domain.provider.BugReportLogProvider
+import javax.inject.Inject
+
+class BugReportLogProviderImpl @Inject constructor(
+ private val getAggregatedEventsZipFile: GetAggregatedEventsZipFile
+) : BugReportLogProvider {
+
+ override suspend fun getLog(): File? = getAggregatedEventsZipFile().getOrNull()
+
+ override suspend fun releaseLog(log: File) = Unit
+}
diff --git a/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/provider/LogcatProviderImpl.kt b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/provider/LogcatProviderImpl.kt
new file mode 100644
index 0000000000..71c11d4bec
--- /dev/null
+++ b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/provider/LogcatProviderImpl.kt
@@ -0,0 +1,82 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.data.provider
+
+import java.io.File
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import android.content.Context
+import arrow.core.Either
+import arrow.core.left
+import arrow.core.right
+import ch.protonmail.android.mailbugreport.domain.provider.LogcatProvider
+import ch.protonmail.android.mailbugreport.domain.provider.LogcatProviderError
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import javax.inject.Inject
+
+class LogcatProviderImpl @Inject constructor(
+ @ApplicationContext val context: Context
+) : LogcatProvider {
+
+ override fun getParentPath(): File = File(context.cacheDir, LogsDirName)
+
+ @Suppress("TooGenericExceptionCaught")
+ override suspend fun getLogcatFile(): Either = withContext(Dispatchers.IO) {
+ runCatching {
+ val logDir = getParentPath()
+ if (!logDir.exists()) logDir.mkdirs()
+
+ val packageName = context.packageName
+ val logFile = File(logDir, "logcat_$packageName.txt")
+
+ val process = Runtime.getRuntime().exec(
+ arrayOf("logcat", "-d", "-v", "time", "*:V", "--t", getCutoffTimestamp())
+ )
+
+ process.inputStream.bufferedReader().useLines { lines ->
+ logFile.bufferedWriter().use { writer ->
+ lines.forEach { line ->
+ writer.appendLine(line)
+ }
+ }
+ }
+
+ logFile.right()
+ }.getOrElse { e ->
+ Timber.e(e, "Error dumping logcat")
+ LogcatProviderError.Error.left()
+ }
+ }
+
+ private fun getCutoffTimestamp(): String {
+ val cutoffInstant = Instant.now().minusSeconds(CutOffTimePeriod)
+ val formatter = DateTimeFormatter.ofPattern("MM-dd HH:mm:ss.000").withZone(ZoneId.systemDefault())
+ return formatter.format(cutoffInstant)
+ }
+
+ private companion object {
+
+ const val CutOffTimePeriod = 12 * 3600L // 12 hours
+ const val LogsDirName = "logcat"
+ }
+}
diff --git a/mail-bugreport/data/src/test/kotlin/ch/protonmail/android/mailbugreport/data/BugReportProviderImplTest.kt b/mail-bugreport/data/src/test/kotlin/ch/protonmail/android/mailbugreport/data/BugReportProviderImplTest.kt
new file mode 100644
index 0000000000..aade262858
--- /dev/null
+++ b/mail-bugreport/data/src/test/kotlin/ch/protonmail/android/mailbugreport/data/BugReportProviderImplTest.kt
@@ -0,0 +1,62 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.data
+
+import java.io.File
+import java.io.IOException
+import ch.protonmail.android.mailbugreport.data.provider.BugReportLogProviderImpl
+import ch.protonmail.android.mailbugreport.domain.usecase.GetAggregatedEventsZipFile
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+internal class BugReportProviderImplTest {
+
+ private val getAggregatedEventsZipFile = mockk()
+ private val bugReportLogProviderImpl = BugReportLogProviderImpl(getAggregatedEventsZipFile)
+
+ @Test
+ fun `should proxy the call to getAggregatedEventsZipFile and return the file when successful`() = runTest {
+ // Given
+ coEvery { getAggregatedEventsZipFile() } returns Result.success(File(""))
+ // When
+ val result = bugReportLogProviderImpl.getLog()
+
+ // Then
+ assertNotNull(result)
+ coVerify(exactly = 1) { getAggregatedEventsZipFile() }
+ }
+
+ @Test
+ fun `should proxy the call to getAggregatedEventsZipFile and return null when unsuccessful`() = runTest {
+ // Given
+ coEvery { getAggregatedEventsZipFile() } returns Result.failure(IOException())
+
+ // When
+ val result = bugReportLogProviderImpl.getLog()
+
+ // Then
+ assertNull(result)
+ coVerify(exactly = 1) { getAggregatedEventsZipFile() }
+ }
+}
diff --git a/mail-bugreport/data/src/test/kotlin/ch/protonmail/android/mailbugreport/data/LogsFileHandlerImplTest.kt b/mail-bugreport/data/src/test/kotlin/ch/protonmail/android/mailbugreport/data/LogsFileHandlerImplTest.kt
new file mode 100644
index 0000000000..1f81377b1d
--- /dev/null
+++ b/mail-bugreport/data/src/test/kotlin/ch/protonmail/android/mailbugreport/data/LogsFileHandlerImplTest.kt
@@ -0,0 +1,106 @@
+package ch.protonmail.android.mailbugreport.data
+
+import java.io.File
+import android.content.Context
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.Rule
+import org.junit.rules.TemporaryFolder
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+internal class LogsFileHandlerImplTest {
+
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ private lateinit var context: Context
+ private lateinit var logsFileHandler: LogsFileHandlerImpl
+ private lateinit var testDispatcher: TestDispatcher
+ private lateinit var cacheDir: File
+ private lateinit var logDir: File
+
+ @BeforeTest
+ fun setup() {
+ testDispatcher = StandardTestDispatcher()
+ Dispatchers.setMain(testDispatcher)
+
+ cacheDir = tempFolder.newFolder()
+ logDir = File(cacheDir, "logs").apply { mkdirs() }
+
+ context = mockk {
+ every { cacheDir } returns this@LogsFileHandlerImplTest.cacheDir
+ }
+
+ logsFileHandler = LogsFileHandlerImpl(
+ context = context,
+ coroutineDispatcher = testDispatcher
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ unmockkAll()
+ }
+
+ @Test
+ fun `getParentPath returns correct directory`() {
+ // When
+ val parentPath = logsFileHandler.getParentPath()
+
+ // Then
+ assertEquals("logs", parentPath.name)
+ assertTrue(parentPath.exists())
+ assertTrue(parentPath.isDirectory)
+ }
+
+ @Test
+ fun `writeLog creates new file when no files exist`() = runTest(testDispatcher) {
+ // When
+ logsFileHandler.writeLog("Test message")
+ advanceUntilIdle()
+
+ val files = logDir.listFiles()
+
+ // Then
+ assertNotNull(files)
+ assertEquals(1, files.size)
+ assertTrue(files[0].name.startsWith("log-"))
+ assertTrue(files[0].readText().contains("Test message"))
+ }
+
+ @Test
+ fun `calling close properly closes file writer and cancels coroutine scope`() = runTest(testDispatcher) {
+ // When
+ logsFileHandler.writeLog("Test message")
+ advanceUntilIdle()
+
+ logsFileHandler.close()
+
+ // Write after close, won't create new content
+ logsFileHandler.writeLog("After close")
+ advanceUntilIdle()
+
+ val files = logDir.listFiles()
+
+ // Then
+ assertNotNull(files)
+ assertEquals(1, files.size)
+ assertTrue(files[0].readText().contains("Test message"))
+ assertFalse(files[0].readText().contains("After close"))
+ }
+}
diff --git a/mail-bugreport/domain/build.gradle.kts b/mail-bugreport/domain/build.gradle.kts
new file mode 100644
index 0000000000..ed42b498eb
--- /dev/null
+++ b/mail-bugreport/domain/build.gradle.kts
@@ -0,0 +1,50 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ kotlin("kapt")
+ kotlin("plugin.serialization")
+}
+
+android {
+ namespace = "ch.protonmail.android.mailpagination.domain"
+ compileSdk = Config.compileSdk
+
+ defaultConfig {
+ minSdk = Config.minSdk
+ lint.targetSdk = Config.targetSdk
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+}
+
+dependencies {
+ implementation(libs.bundles.module.domain)
+ implementation(libs.proton.core.featureFlag)
+
+ testImplementation(libs.bundles.test)
+}
diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/LogsExportFeatureSetting.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/LogsExportFeatureSetting.kt
new file mode 100644
index 0000000000..8c3bb81211
--- /dev/null
+++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/LogsExportFeatureSetting.kt
@@ -0,0 +1,42 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.domain
+
+/**
+ * Holds the feature flag configurations for the "Application logs" feature accessible from the Settings.
+ *
+ * @param enabled Enables the "Application logs" section in the settings and logs application events
+ * to a file in the internal app storage (cache) during the application lifecycle.
+ * Moreover, it allows users to attach logs when reporting an issue via the "Report a Problem" screen,
+ * accessible from the Sidebar menu.
+ *
+ * @param internalEnabled Enables the intermediate "Application logs" screen and also allows exporting logcat data
+ * from the device in use. This is enabled and available only for the internal QA team to provide logs
+ * to the development team while testing features in development.
+ *
+ * Note: both settings are enabled by default regardless of the feature flags in non-production builds.
+ */
+data class LogsExportFeatureSetting(
+ private val enabled: Boolean,
+ private val internalEnabled: Boolean
+) {
+
+ val isEnabled = enabled || internalEnabled
+ val isInternalFeatureEnabled = internalEnabled
+}
diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/LogsFileHandler.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/LogsFileHandler.kt
new file mode 100644
index 0000000000..d115c1908f
--- /dev/null
+++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/LogsFileHandler.kt
@@ -0,0 +1,29 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.domain
+
+import java.io.File
+
+interface LogsFileHandler {
+
+ fun getParentPath(): File
+ fun getLastLogFile(): File?
+ fun writeLog(message: String)
+ fun close()
+}
diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/annotations/LogsExportingFeatureEnabled.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/annotations/LogsExportingFeatureEnabled.kt
new file mode 100644
index 0000000000..88c8fb0b05
--- /dev/null
+++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/annotations/LogsExportingFeatureEnabled.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.mailbugreport.domain.annotations
+
+import javax.inject.Qualifier
+
+/**
+ * Provider for the "Application logs" related feature flags.
+ */
+@Qualifier
+annotation class LogsExportFeatureSettingValue
diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/featureflags/IsLogsExportingFeatureEnabled.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/featureflags/IsLogsExportingFeatureEnabled.kt
new file mode 100644
index 0000000000..b375723ca4
--- /dev/null
+++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/featureflags/IsLogsExportingFeatureEnabled.kt
@@ -0,0 +1,37 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.domain.featureflags
+
+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 IsLogsExportingFeatureEnabled @Inject constructor(
+ private val featureFlagManager: FeatureFlagManager
+) {
+
+ @OptIn(ExperimentalProtonFeatureFlag::class)
+ operator fun invoke() = featureFlagManager.getValue(null, FeatureId(FeatureFlagId))
+
+ private companion object {
+
+ const val FeatureFlagId = "MailAndroidLogsExportingFeature"
+ }
+}
diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/featureflags/IsLogsExportingInternalFeatureEnabled.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/featureflags/IsLogsExportingInternalFeatureEnabled.kt
new file mode 100644
index 0000000000..70c5fe85e4
--- /dev/null
+++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/featureflags/IsLogsExportingInternalFeatureEnabled.kt
@@ -0,0 +1,37 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.domain.featureflags
+
+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 IsLogsExportingInternalFeatureEnabled @Inject constructor(
+ private val featureFlagManager: FeatureFlagManager
+) {
+
+ @OptIn(ExperimentalProtonFeatureFlag::class)
+ operator fun invoke() = featureFlagManager.getValue(null, FeatureId(FeatureFlagId))
+
+ private companion object {
+
+ const val FeatureFlagId = "MailAndroidLogsExportingInternalFeature"
+ }
+}
diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/provider/LogcatProvider.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/provider/LogcatProvider.kt
new file mode 100644
index 0000000000..d0020ef24f
--- /dev/null
+++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/provider/LogcatProvider.kt
@@ -0,0 +1,32 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.domain.provider
+
+import java.io.File
+import arrow.core.Either
+
+interface LogcatProvider {
+
+ suspend fun getLogcatFile(): Either
+ fun getParentPath(): File
+}
+
+interface LogcatProviderError {
+ data object Error : LogcatProviderError
+}
diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/usecase/GetAggregatedEventsZipFile.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/usecase/GetAggregatedEventsZipFile.kt
new file mode 100644
index 0000000000..4a18ce2567
--- /dev/null
+++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/usecase/GetAggregatedEventsZipFile.kt
@@ -0,0 +1,95 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.domain.usecase
+
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+import android.content.Context
+import ch.protonmail.android.mailbugreport.domain.LogsExportFeatureSetting
+import ch.protonmail.android.mailbugreport.domain.LogsFileHandler
+import ch.protonmail.android.mailbugreport.domain.annotations.LogsExportFeatureSettingValue
+import ch.protonmail.android.mailbugreport.domain.provider.LogcatProvider
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+class GetAggregatedEventsZipFile @Inject constructor(
+ @ApplicationContext private val applicationContext: Context,
+ private val logcatProvider: LogcatProvider,
+ private val logsFileHandler: LogsFileHandler,
+ @LogsExportFeatureSettingValue private val logsExportFeatureSetting: LogsExportFeatureSetting
+) {
+
+ suspend operator fun invoke() = withContext(Dispatchers.IO) {
+ runCatching {
+ val directoriesList = buildDirectoriesList(logsExportFeatureSetting)
+ val outputFile = File(applicationContext.cacheDir, FilePath).also {
+ it.mkdirs()
+ if (it.exists()) it.delete()
+ }
+
+ FileOutputStream(outputFile).use { fos ->
+ ZipOutputStream(fos).use { zos ->
+ directoriesList.forEach { file ->
+ zipFileOrDirectory(file, zos)
+ }
+ }
+ }
+ outputFile
+ }
+ }
+
+ private fun zipFileOrDirectory(
+ file: File,
+ zos: ZipOutputStream,
+ baseName: String = ""
+ ) {
+ if (file.isDirectory) {
+ file.listFiles()?.forEach { child ->
+ zipFileOrDirectory(child, zos, "$baseName${file.name}/")
+ }
+ } else {
+ FileInputStream(file).use { fis ->
+ val entryName = "$baseName${file.name}"
+ zos.putNextEntry(ZipEntry(entryName))
+ fis.copyTo(zos, bufferSize = 1024)
+ }
+ }
+ }
+
+ private suspend fun buildDirectoriesList(logsExportFeatureSetting: LogsExportFeatureSetting): List {
+ return buildList {
+ // Attaching logcat is only for internal QA, this is not enabled for end users.
+ if (logsExportFeatureSetting.isInternalFeatureEnabled) {
+ logcatProvider.getLogcatFile()
+ add(logcatProvider.getParentPath())
+ }
+ add(logsFileHandler.getParentPath())
+ }
+ }
+
+ private companion object {
+
+ const val FilePath = "export_logs/protonmail_events.zip"
+ }
+}
diff --git a/mail-bugreport/domain/src/test/kotlin/ch/protonmail/android/mailbugreport/domain/usecase/GetAggregatedEventsZipFileTest.kt b/mail-bugreport/domain/src/test/kotlin/ch/protonmail/android/mailbugreport/domain/usecase/GetAggregatedEventsZipFileTest.kt
new file mode 100644
index 0000000000..d4b06553b6
--- /dev/null
+++ b/mail-bugreport/domain/src/test/kotlin/ch/protonmail/android/mailbugreport/domain/usecase/GetAggregatedEventsZipFileTest.kt
@@ -0,0 +1,147 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.domain.usecase
+
+import java.io.File
+import android.content.Context
+import ch.protonmail.android.mailbugreport.domain.LogsExportFeatureSetting
+import ch.protonmail.android.mailbugreport.domain.LogsFileHandler
+import ch.protonmail.android.mailbugreport.domain.provider.LogcatProvider
+import io.mockk.called
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import javax.inject.Provider
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class GetAggregatedEventsZipFileTest {
+
+ private val context: Context = mockk(relaxed = true)
+ private val logcatProvider = mockk()
+ private val logsFileHandler = mockk()
+ private val logsExportFeatureSetting = mockk> {
+ every { this@mockk.get() } returns DefaultExportSettings
+ }
+ private val getAggregatedEventsZipFile: GetAggregatedEventsZipFile
+ get() = GetAggregatedEventsZipFile(context, logcatProvider, logsFileHandler, logsExportFeatureSetting.get())
+
+ @Before
+ fun setup() {
+ unmockkAll()
+ val tempCacheDir = File(System.getProperty("java.io.tmpdir"), "test-cache")
+ .apply { mkdirs() }
+ every { context.cacheDir } returns tempCacheDir
+ }
+
+ @Test
+ fun `invoke creates zip file with correct name`() = runTest {
+ // Arrange
+ val mockLogcatDir = mockk()
+ val mockLogsDir = mockk()
+
+ coEvery { logcatProvider.getLogcatFile() } returns mockk()
+ every { logcatProvider.getParentPath() } returns mockLogcatDir
+ every { logsFileHandler.getParentPath() } returns mockLogsDir
+ every { mockLogcatDir.isDirectory } returns true
+ every { mockLogsDir.isDirectory } returns true
+ every { mockLogcatDir.listFiles() } returns emptyArray()
+ every { mockLogsDir.listFiles() } returns emptyArray()
+
+ // Act
+ val result = getAggregatedEventsZipFile()
+
+ // Assert
+ assertTrue(result.isSuccess)
+ result.getOrNull()?.let { zipFile ->
+ assertEquals("protonmail_events.zip", zipFile.name)
+ assertTrue(zipFile.exists())
+ }
+ }
+
+ @Test
+ fun `invoke skips logcat when the internal feature flag is disabled`() = runTest {
+ // Arrange
+ val mockLogsDir = mockk()
+
+ every { logsExportFeatureSetting.get() } returns
+ LogsExportFeatureSetting(enabled = true, internalEnabled = false)
+
+ every { logsFileHandler.getParentPath() } returns mockLogsDir
+ every { mockLogsDir.isDirectory } returns true
+ every { mockLogsDir.listFiles() } returns emptyArray()
+
+ // Act
+ val result = getAggregatedEventsZipFile()
+
+ // Assert
+ assertTrue(result.isSuccess)
+ result.getOrNull()?.let { zipFile ->
+ assertEquals("protonmail_events.zip", zipFile.name)
+ assertTrue(zipFile.exists())
+ }
+ verify { logcatProvider wasNot called }
+ }
+
+ @Test
+ fun `invoke fails when logcat directory does not exist`() = runTest {
+ // Arrange
+ val mockLogcatDir = mockk()
+ val mockLogsDir = mockk()
+
+ every { logcatProvider.getParentPath() } returns mockLogcatDir
+ every { logsFileHandler.getParentPath() } returns mockLogsDir
+ every { mockLogcatDir.exists() } returns false
+
+ // Act
+ val result = getAggregatedEventsZipFile()
+
+ // Assert
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `invoke fails when events directory does not exist`() = runTest {
+ // Arrange
+ val mockLogcatDir = mockk()
+ val mockLogsDir = mockk()
+
+ every { logcatProvider.getParentPath() } returns mockLogcatDir
+ every { logsFileHandler.getParentPath() } returns mockLogsDir
+ every { mockLogcatDir.exists() } returns true
+ every { mockLogsDir.exists() } returns false
+
+ // Act
+ val result = getAggregatedEventsZipFile()
+
+ // Assert
+ assertTrue(result.isFailure)
+ }
+
+ private companion object {
+
+ val DefaultExportSettings = LogsExportFeatureSetting(enabled = true, internalEnabled = true)
+ }
+}
+
diff --git a/mail-bugreport/presentation/build.gradle.kts b/mail-bugreport/presentation/build.gradle.kts
new file mode 100644
index 0000000000..fa769f8359
--- /dev/null
+++ b/mail-bugreport/presentation/build.gradle.kts
@@ -0,0 +1,62 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ kotlin("kapt")
+ kotlin("plugin.serialization")
+ id("dagger.hilt.android.plugin")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+ namespace = "ch.protonmail.android.mailbugreport.presentation"
+ compileSdk = Config.compileSdk
+
+ defaultConfig {
+ minSdk = Config.minSdk
+ lint.targetSdk = Config.targetSdk
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ kapt(libs.bundles.app.annotationProcessors)
+ debugImplementation(libs.bundles.compose.debug)
+
+ implementation(libs.bundles.module.presentation)
+ implementation(libs.dagger.hilt.android)
+
+ implementation(project(":mail-bugreport:domain"))
+ implementation(project(":mail-common:presentation"))
+
+ testImplementation(libs.bundles.test)
+}
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsFileUiModel.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsFileUiModel.kt
new file mode 100644
index 0000000000..ade4b925f0
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsFileUiModel.kt
@@ -0,0 +1,29 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.model
+
+import java.io.File
+import androidx.compose.runtime.Stable
+
+@Stable
+data class ApplicationLogsFileUiModel(
+ val rawFile: File,
+ val fileName: String,
+ val fileContents: List
+)
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsOperation.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsOperation.kt
new file mode 100644
index 0000000000..14f5061d8e
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsOperation.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.model
+
+import java.io.File
+
+sealed interface ApplicationLogsOperation {
+
+ sealed interface ApplicationLogsAction : ApplicationLogsOperation {
+
+ sealed interface Export : ApplicationLogsAction {
+ data object ShareLogs : Export
+ data object ExportLogs : Export
+ }
+
+ sealed interface View : ApplicationLogsAction {
+ data object ViewLogcat : View
+ data object ViewEvents : View
+ }
+ }
+
+ sealed interface ApplicationLogsEvent : ApplicationLogsOperation {
+ sealed interface Export : ApplicationLogsEvent {
+ data class ShareReady(val file: File) : Export
+ data class ExportReady(val file: File) : Export
+ }
+
+ sealed interface View : ApplicationLogsEvent {
+ data object LogcatReady : View
+ data object EventsReady : View
+ }
+ }
+}
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsPeekViewAction.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsPeekViewAction.kt
new file mode 100644
index 0000000000..1946ad8e54
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsPeekViewAction.kt
@@ -0,0 +1,35 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.model
+
+import java.io.File
+
+sealed interface ApplicationLogsPeekViewOperation {
+
+ sealed interface ViewAction : ApplicationLogsPeekViewOperation {
+ data object DisplayFileContent : ViewAction
+ }
+
+ sealed interface ViewEvent : ApplicationLogsPeekViewOperation {
+ data class FileContentLoaded(val file: File) : ViewEvent
+ data object FileContentLoadError : ViewEvent
+ data object InvalidOpenMode : ViewEvent
+ data object Loading : ViewEvent
+ }
+}
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsState.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsState.kt
new file mode 100644
index 0000000000..e07130cf5f
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsState.kt
@@ -0,0 +1,31 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.model
+
+import java.io.File
+import ch.protonmail.android.mailcommon.presentation.Effect
+import ch.protonmail.android.mailcommon.presentation.model.TextUiModel
+
+data class ApplicationLogsState(
+ val error: Effect,
+ val showApplicationLogs: Effect,
+ val showLogcat: Effect,
+ val share: Effect,
+ val export: Effect
+)
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsViewItemMode.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsViewItemMode.kt
new file mode 100644
index 0000000000..ec30b56f9e
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsViewItemMode.kt
@@ -0,0 +1,31 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed interface ApplicationLogsViewItemMode {
+
+ @Serializable
+ data object Logcat : ApplicationLogsViewItemMode
+
+ @Serializable
+ data object Events : ApplicationLogsViewItemMode
+}
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsViewState.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsViewState.kt
new file mode 100644
index 0000000000..9cc8c3e6e6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsViewState.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.model
+
+sealed interface ApplicationLogsPeekViewState {
+ data object Loading : ApplicationLogsPeekViewState
+ data class Loaded(val uiModel: ApplicationLogsFileUiModel) : ApplicationLogsPeekViewState
+ data object Error : ApplicationLogsPeekViewState
+}
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekView.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekView.kt
new file mode 100644
index 0000000000..a4ec62ff20
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekView.kt
@@ -0,0 +1,55 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsPeekViewOperation
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsPeekViewState
+import ch.protonmail.android.mailbugreport.presentation.viewmodel.ApplicationLogsPeekViewViewModel
+import me.proton.core.compose.component.ProtonCenteredProgress
+
+@Composable
+fun ApplicationLogsPeekView(onBack: () -> Unit) {
+
+ val viewModel = hiltViewModel()
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ when (state) {
+ is ApplicationLogsPeekViewState.Loaded -> ApplicationLogsPeekViewContent(
+ state as ApplicationLogsPeekViewState.Loaded,
+ onBack
+ )
+
+ ApplicationLogsPeekViewState.Loading -> ProtonCenteredProgress()
+ ApplicationLogsPeekViewState.Error -> ApplicationLogsPeekViewError(onBack)
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.submit(ApplicationLogsPeekViewOperation.ViewAction.DisplayFileContent)
+ }
+}
+
+object ApplicationLogsPeekView {
+
+ const val ApplicationLogsViewMode = "application_logs_view_mode"
+}
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekViewContent.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekViewContent.kt
new file mode 100644
index 0000000000..02c0e1bb3c
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekViewContent.kt
@@ -0,0 +1,126 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.ui
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.sp
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsPeekViewState
+import ch.protonmail.android.mailbugreport.presentation.utils.ApplicationLogsUtils.shareLogs
+import kotlinx.coroutines.launch
+import me.proton.core.compose.component.appbar.ProtonTopAppBar
+import me.proton.core.compose.theme.ProtonDimens
+import me.proton.core.compose.theme.ProtonTheme
+
+@Composable
+internal fun ApplicationLogsPeekViewContent(
+ state: ApplicationLogsPeekViewState.Loaded,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val lazyListState = rememberLazyListState()
+ val context = LocalContext.current
+
+ val contents = state.uiModel.fileContents
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ ProtonTopAppBar(
+ modifier = modifier.fillMaxWidth(),
+ title = {
+ Text(
+ modifier = Modifier.clickable {
+ coroutineScope.launch { lazyListState.animateScrollToItem(0) }
+ },
+ text = state.uiModel.fileName,
+ overflow = TextOverflow.Ellipsis,
+ color = ProtonTheme.colors.textNorm
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ tint = ProtonTheme.colors.iconNorm,
+ contentDescription = null
+ )
+ }
+ },
+ actions = {
+ IconButton(
+ onClick = { coroutineScope.launch { lazyListState.animateScrollToItem(contents.size) } }
+ ) {
+ Icon(
+ Icons.Filled.KeyboardArrowDown,
+ tint = ProtonTheme.colors.iconNorm,
+ contentDescription = null
+ )
+ }
+ IconButton(
+ onClick = {
+ coroutineScope.launch { context.shareLogs(state.uiModel.rawFile) }
+ }
+ ) {
+ Icon(Icons.Filled.Share, tint = ProtonTheme.colors.iconNorm, contentDescription = null)
+ }
+ }
+ )
+ }
+ ) { contentPadding ->
+ Box(modifier = Modifier.padding(contentPadding)) {
+ SelectionContainer {
+ LazyColumn(
+ state = lazyListState,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(ProtonDimens.ExtraSmallSpacing)
+ .animateContentSize()
+ ) {
+ // Load in chunks as the file contents could be huge and take a while to render.
+ items(state.uiModel.fileContents) { chunk ->
+ Text(text = chunk, fontSize = 12.sp)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekViewError.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekViewError.kt
new file mode 100644
index 0000000000..ed70bbe6de
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekViewError.kt
@@ -0,0 +1,35 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.platform.LocalContext
+import me.proton.core.presentation.utils.showToast
+
+@Composable
+internal fun ApplicationLogsPeekViewError(onBack: () -> Unit) {
+ val context = LocalContext.current
+
+ LaunchedEffect(Unit) {
+ context.showToast("Unable to retrieve file contents.")
+ }
+
+ onBack()
+}
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsScreen.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsScreen.kt
new file mode 100644
index 0000000000..ffbb80777e
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsScreen.kt
@@ -0,0 +1,119 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.ui
+
+import java.io.File
+import java.io.IOException
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.Scaffold
+import androidx.compose.material.rememberScaffoldState
+import androidx.compose.runtime.Composable
+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.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import ch.protonmail.android.mailbugreport.presentation.R
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsOperation.ApplicationLogsAction.Export
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsOperation.ApplicationLogsAction.View
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsViewItemMode
+import ch.protonmail.android.mailbugreport.presentation.utils.ApplicationLogsUtils.shareLogs
+import ch.protonmail.android.mailbugreport.presentation.viewmodel.ApplicationLogsViewModel
+import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect
+import ch.protonmail.android.mailcommon.presentation.ConsumableTextEffect
+import me.proton.core.compose.component.ProtonSettingsTopBar
+import me.proton.core.presentation.utils.showToast
+import timber.log.Timber
+
+@Composable
+fun ApplicationLogsScreen(
+ modifier: Modifier = Modifier,
+ onBackClick: () -> Unit,
+ onViewItemClick: (ApplicationLogsViewItemMode) -> Unit,
+ viewModel: ApplicationLogsViewModel = hiltViewModel()
+) {
+ val scaffoldState = rememberScaffoldState()
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ var file by remember { mutableStateOf(File("")) }
+
+ val fileSaveLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument("application/zip")
+ ) { uri ->
+ uri?.let {
+ try {
+ context.contentResolver.openOutputStream(uri)?.use { outputStream ->
+ file.inputStream().use { inputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ }
+ } catch (e: IOException) {
+ Timber.e("FileSave", "Error copying file", e)
+ }
+ }
+ }
+
+ val actions = ApplicationLogsScreenList.Actions(
+ onExport = { viewModel.submit(Export.ExportLogs) },
+ onShare = { viewModel.submit(Export.ShareLogs) },
+ onShowLogcat = { viewModel.submit(View.ViewLogcat) },
+ onShowEvents = { viewModel.submit(View.ViewEvents) }
+ )
+
+ ConsumableLaunchedEffect(state.showApplicationLogs) {
+ onViewItemClick(ApplicationLogsViewItemMode.Events)
+ }
+
+ ConsumableLaunchedEffect(state.showLogcat) {
+ onViewItemClick(ApplicationLogsViewItemMode.Logcat)
+ }
+
+ ConsumableLaunchedEffect(state.share) {
+ context.shareLogs(it)
+ }
+
+ ConsumableLaunchedEffect(state.export) {
+ file = it
+ fileSaveLauncher.launch(it.name)
+ }
+
+ ConsumableTextEffect(state.error) { message ->
+ context.showToast(message)
+ }
+
+ Scaffold(
+ modifier = modifier,
+ scaffoldState = scaffoldState,
+ topBar = {
+ ProtonSettingsTopBar(
+ title = stringResource(R.string.application_events_title),
+ onBackClick = onBackClick
+ )
+ },
+ content = { paddingValues ->
+ ApplicationLogsScreenList(Modifier.padding(paddingValues), actions)
+ }
+ )
+}
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsScreenList.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsScreenList.kt
new file mode 100644
index 0000000000..8e0fe92081
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsScreenList.kt
@@ -0,0 +1,104 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.ui
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import ch.protonmail.android.mailbugreport.presentation.R
+import me.proton.core.compose.component.ProtonSettingsHeader
+import me.proton.core.compose.component.ProtonSettingsItem
+import me.proton.core.compose.theme.ProtonDimens
+import me.proton.core.compose.theme.ProtonTheme
+
+@Composable
+internal fun ApplicationLogsScreenList(modifier: Modifier = Modifier, actions: ApplicationLogsScreenList.Actions) {
+ LazyColumn(modifier = modifier) {
+ item {
+ ProtonSettingsHeader(
+ title = stringResource(R.string.application_events_header_view),
+ modifier = Modifier.padding(bottom = ProtonDimens.SmallSpacing)
+ )
+ }
+ item {
+ ProtonSettingsItem(
+ name = stringResource(R.string.application_events_view_logcat),
+ hint = stringResource(R.string.application_events_view_logcat_hint),
+ onClick = actions.onShowLogcat
+ )
+ }
+ item { HorizontalDivider(color = ProtonTheme.colors.separatorNorm) }
+ item {
+ ProtonSettingsItem(
+ name = stringResource(R.string.application_events_view_events),
+ hint = stringResource(R.string.application_events_view_events_hint),
+ onClick = actions.onShowEvents
+ )
+ }
+ item { HorizontalDivider(color = ProtonTheme.colors.separatorNorm) }
+
+ item { ProtonSettingsHeader(title = "Export", modifier = Modifier.padding(bottom = ProtonDimens.SmallSpacing)) }
+ item {
+ ProtonSettingsItem(
+ name = stringResource(R.string.application_events_share),
+ onClick = actions.onShare
+ )
+ }
+ item { HorizontalDivider(color = ProtonTheme.colors.separatorNorm) }
+ item {
+ ProtonSettingsItem(
+ name = stringResource(R.string.application_events_save_to_disk),
+ onClick = actions.onExport
+ )
+ }
+ item { HorizontalDivider(color = ProtonTheme.colors.separatorNorm) }
+ }
+}
+
+object ApplicationLogsScreenList {
+ data class Actions(
+ val onExport: () -> Unit,
+ val onShare: () -> Unit,
+ val onShowEvents: () -> Unit,
+ val onShowLogcat: () -> Unit
+ ) {
+
+ companion object {
+
+ val Empty = Actions(
+ onExport = {},
+ onShare = {},
+ onShowEvents = {},
+ onShowLogcat = {}
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun ApplicationLogsScreenPreview() {
+ ProtonTheme {
+ ApplicationLogsScreenList(actions = ApplicationLogsScreenList.Actions.Empty)
+ }
+}
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/utils/ApplicationLogsUtils.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/utils/ApplicationLogsUtils.kt
new file mode 100644
index 0000000000..c590dfa115
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/utils/ApplicationLogsUtils.kt
@@ -0,0 +1,50 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.utils
+
+import java.io.File
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+internal object ApplicationLogsUtils {
+ private const val FileProviderSuffix = ".logsfileprovider"
+
+ suspend fun Context.shareLogs(file: File) {
+ val fileUri = withContext(Dispatchers.IO) {
+ FileProvider.getUriForFile(
+ this@shareLogs,
+ "$packageName$FileProviderSuffix",
+ file
+ )
+ }
+
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "application/zip"
+ clipData = ClipData.newRawUri("", fileUri)
+ putExtra(Intent.EXTRA_STREAM, fileUri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+
+ startActivity(Intent.createChooser(shareIntent, "Share File"))
+ }
+}
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/viewmodel/ApplicationLogsPeekViewViewModel.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/viewmodel/ApplicationLogsPeekViewViewModel.kt
new file mode 100644
index 0000000000..5821341db5
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/viewmodel/ApplicationLogsPeekViewViewModel.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.viewmodel
+
+import java.io.File
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import ch.protonmail.android.mailbugreport.domain.LogsFileHandler
+import ch.protonmail.android.mailbugreport.domain.provider.LogcatProvider
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsFileUiModel
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsPeekViewOperation
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsPeekViewState
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsViewItemMode
+import ch.protonmail.android.mailbugreport.presentation.ui.ApplicationLogsPeekView
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import me.proton.core.util.kotlin.deserialize
+import javax.inject.Inject
+
+@HiltViewModel
+class ApplicationLogsPeekViewViewModel @Inject constructor(
+ private val logsFileHandler: LogsFileHandler,
+ private val logcatProvider: LogcatProvider,
+ savedStateHandle: SavedStateHandle
+) : ViewModel() {
+
+ private val openMode = savedStateHandle
+ .get(ApplicationLogsPeekView.ApplicationLogsViewMode)
+ ?.deserialize()
+
+ private val mutableState = MutableStateFlow(ApplicationLogsPeekViewState.Loading)
+ val state = mutableState.asStateFlow()
+
+ fun submit(action: ApplicationLogsPeekViewOperation.ViewAction) {
+ viewModelScope.launch {
+ when (action) {
+ ApplicationLogsPeekViewOperation.ViewAction.DisplayFileContent -> handleItemDisplay(openMode)
+ }
+ }
+ }
+
+ private suspend fun handleItemDisplay(openMode: ApplicationLogsViewItemMode?) {
+ emitNewStateFromEvent(ApplicationLogsPeekViewOperation.ViewEvent.Loading)
+ openMode ?: return emitNewStateFromEvent(ApplicationLogsPeekViewOperation.ViewEvent.InvalidOpenMode)
+
+ val file = when (openMode) {
+ ApplicationLogsViewItemMode.Events -> logsFileHandler.getLastLogFile()
+ ApplicationLogsViewItemMode.Logcat -> logcatProvider.getLogcatFile().getOrNull()
+ }
+ ?.takeIf { withContext(Dispatchers.IO) { it.exists() } }
+ ?: return emitNewStateFromEvent(ApplicationLogsPeekViewOperation.ViewEvent.FileContentLoadError)
+
+ emitNewStateFromEvent(ApplicationLogsPeekViewOperation.ViewEvent.FileContentLoaded(file))
+ }
+
+ private suspend fun emitNewStateFromEvent(event: ApplicationLogsPeekViewOperation.ViewEvent) {
+ when (event) {
+ is ApplicationLogsPeekViewOperation.ViewEvent.FileContentLoaded -> mutableState.update {
+ ApplicationLogsPeekViewState.Loaded(event.file.toUiModel())
+ }
+
+ ApplicationLogsPeekViewOperation.ViewEvent.FileContentLoadError,
+ ApplicationLogsPeekViewOperation.ViewEvent.InvalidOpenMode -> {
+ mutableState.update { ApplicationLogsPeekViewState.Error }
+ }
+
+ ApplicationLogsPeekViewOperation.ViewEvent.Loading -> mutableState.update {
+ ApplicationLogsPeekViewState.Loading
+ }
+ }
+ }
+
+ private suspend fun File.toUiModel() = withContext(Dispatchers.IO) {
+ val chunkedContents = readLines().chunked(ChunkLines).map { it.joinToString(separator = "\n") }
+ ApplicationLogsFileUiModel(this@toUiModel, name, chunkedContents)
+ }
+
+ private companion object {
+ const val ChunkLines = 200
+ }
+}
diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/viewmodel/ApplicationLogsViewModel.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/viewmodel/ApplicationLogsViewModel.kt
new file mode 100644
index 0000000000..e2b93e1abc
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/viewmodel/ApplicationLogsViewModel.kt
@@ -0,0 +1,111 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailbugreport.presentation.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import ch.protonmail.android.mailbugreport.domain.usecase.GetAggregatedEventsZipFile
+import ch.protonmail.android.mailbugreport.presentation.R
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsOperation
+import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsState
+import ch.protonmail.android.mailcommon.presentation.Effect
+import ch.protonmail.android.mailcommon.presentation.model.TextUiModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+@HiltViewModel
+class ApplicationLogsViewModel @Inject constructor(
+ private val getAggregatedEventsZipFile: GetAggregatedEventsZipFile
+) : ViewModel() {
+
+ private val mutableState = MutableStateFlow(
+ ApplicationLogsState(
+ error = Effect.empty(),
+ showApplicationLogs = Effect.empty(),
+ showLogcat = Effect.empty(),
+ share = Effect.empty(),
+ export = Effect.empty()
+ )
+ )
+
+ val state: StateFlow = mutableState.asStateFlow()
+
+ fun submit(action: ApplicationLogsOperation.ApplicationLogsAction) {
+ viewModelScope.launch {
+ when (action) {
+ is ApplicationLogsOperation.ApplicationLogsAction.Export -> handleExportAction(action)
+ is ApplicationLogsOperation.ApplicationLogsAction.View -> handleViewAction(action)
+ }
+ }
+ }
+
+ private suspend fun handleExportAction(action: ApplicationLogsOperation.ApplicationLogsAction.Export) {
+ val zipFile = withContext(Dispatchers.IO) { getAggregatedEventsZipFile() }.getOrElse {
+ mutableState.value = mutableState.value.copy(
+ error = Effect.of(TextUiModel.TextRes(R.string.application_events_export_error))
+ )
+ return
+ }
+
+ when (action) {
+ ApplicationLogsOperation.ApplicationLogsAction.Export.ExportLogs ->
+ emitNewStateFromEvent(ApplicationLogsOperation.ApplicationLogsEvent.Export.ExportReady(zipFile))
+
+ ApplicationLogsOperation.ApplicationLogsAction.Export.ShareLogs ->
+ emitNewStateFromEvent(ApplicationLogsOperation.ApplicationLogsEvent.Export.ShareReady(zipFile))
+ }
+ }
+
+ private fun handleViewAction(action: ApplicationLogsOperation.ApplicationLogsAction.View) {
+ when (action) {
+ ApplicationLogsOperation.ApplicationLogsAction.View.ViewEvents ->
+ emitNewStateFromEvent(ApplicationLogsOperation.ApplicationLogsEvent.View.EventsReady)
+
+ ApplicationLogsOperation.ApplicationLogsAction.View.ViewLogcat ->
+ emitNewStateFromEvent(ApplicationLogsOperation.ApplicationLogsEvent.View.LogcatReady)
+ }
+ }
+
+ private fun emitNewStateFromEvent(event: ApplicationLogsOperation.ApplicationLogsEvent) {
+ when (event) {
+ is ApplicationLogsOperation.ApplicationLogsEvent.Export.ShareReady -> {
+ mutableState.update { mutableState.value.copy(share = Effect.of(event.file)) }
+ }
+
+ is ApplicationLogsOperation.ApplicationLogsEvent.Export.ExportReady -> {
+ mutableState.update { mutableState.value.copy(export = Effect.of(event.file)) }
+ }
+
+ ApplicationLogsOperation.ApplicationLogsEvent.View.EventsReady -> {
+ mutableState.update { mutableState.value.copy(showApplicationLogs = Effect.of(Unit)) }
+ }
+
+ ApplicationLogsOperation.ApplicationLogsEvent.View.LogcatReady -> {
+ mutableState.update { mutableState.value.copy(showLogcat = Effect.of(Unit)) }
+ }
+ }
+ }
+}
diff --git a/mail-bugreport/presentation/src/main/res/values-b+es+419/strings.xml b/mail-bugreport/presentation/src/main/res/values-b+es+419/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-b+es+419/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-be/strings.xml b/mail-bugreport/presentation/src/main/res/values-be/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-be/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-ca/strings.xml b/mail-bugreport/presentation/src/main/res/values-ca/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-ca/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-cs/strings.xml b/mail-bugreport/presentation/src/main/res/values-cs/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-cs/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-da/strings.xml b/mail-bugreport/presentation/src/main/res/values-da/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-da/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-de/strings.xml b/mail-bugreport/presentation/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-de/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-el/strings.xml b/mail-bugreport/presentation/src/main/res/values-el/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-el/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-es-rES/strings.xml b/mail-bugreport/presentation/src/main/res/values-es-rES/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-es-rES/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-fi/strings.xml b/mail-bugreport/presentation/src/main/res/values-fi/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-fi/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-fr/strings.xml b/mail-bugreport/presentation/src/main/res/values-fr/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-fr/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-hi/strings.xml b/mail-bugreport/presentation/src/main/res/values-hi/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-hi/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-hr/strings.xml b/mail-bugreport/presentation/src/main/res/values-hr/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-hr/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-hu/strings.xml b/mail-bugreport/presentation/src/main/res/values-hu/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-hu/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-in/strings.xml b/mail-bugreport/presentation/src/main/res/values-in/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-in/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-it/strings.xml b/mail-bugreport/presentation/src/main/res/values-it/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-it/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-ja/strings.xml b/mail-bugreport/presentation/src/main/res/values-ja/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-ja/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-ka/strings.xml b/mail-bugreport/presentation/src/main/res/values-ka/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-ka/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-kab/strings.xml b/mail-bugreport/presentation/src/main/res/values-kab/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-kab/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-ko/strings.xml b/mail-bugreport/presentation/src/main/res/values-ko/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-ko/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-nb-rNO/strings.xml b/mail-bugreport/presentation/src/main/res/values-nb-rNO/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-nb-rNO/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-nl/strings.xml b/mail-bugreport/presentation/src/main/res/values-nl/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-nl/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-pl/strings.xml b/mail-bugreport/presentation/src/main/res/values-pl/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-pl/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-pt-rBR/strings.xml b/mail-bugreport/presentation/src/main/res/values-pt-rBR/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-pt-rBR/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-pt-rPT/strings.xml b/mail-bugreport/presentation/src/main/res/values-pt-rPT/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-pt-rPT/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-ro/strings.xml b/mail-bugreport/presentation/src/main/res/values-ro/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-ro/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-ru/strings.xml b/mail-bugreport/presentation/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-ru/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-sk/strings.xml b/mail-bugreport/presentation/src/main/res/values-sk/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-sk/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-sl/strings.xml b/mail-bugreport/presentation/src/main/res/values-sl/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-sl/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-sv-rSE/strings.xml b/mail-bugreport/presentation/src/main/res/values-sv-rSE/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-sv-rSE/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-tr/strings.xml b/mail-bugreport/presentation/src/main/res/values-tr/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-tr/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-uk/strings.xml b/mail-bugreport/presentation/src/main/res/values-uk/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-uk/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-zh-rCN/strings.xml b/mail-bugreport/presentation/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values-zh-rTW/strings.xml b/mail-bugreport/presentation/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 0000000000..6dda6805c6
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-bugreport/presentation/src/main/res/values/strings.xml b/mail-bugreport/presentation/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..0fe7a427bc
--- /dev/null
+++ b/mail-bugreport/presentation/src/main/res/values/strings.xml
@@ -0,0 +1,37 @@
+
+
+
+
+ Application logs
+
+ View logs
+ View logcat
+
+ Display a dump of the logcat events
+ View application events
+ Display the latest application events
+
+ Export
+ Share logs
+ Save logs to disk
+ An error occurred while exporting the events
+
\ No newline at end of file
diff --git a/mail-bugreport/src/main/AndroidManifest.xml b/mail-bugreport/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..dbeff4a09b
--- /dev/null
+++ b/mail-bugreport/src/main/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/mail-common/build.gradle.kts b/mail-common/build.gradle.kts
new file mode 100644
index 0000000000..d68c90a05e
--- /dev/null
+++ b/mail-common/build.gradle.kts
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ id("com.android.library")
+ kotlin("android")
+}
+
+android {
+ namespace = "ch.protonmail.android.mailcommon"
+ compileSdk = Config.compileSdk
+
+ defaultConfig {
+ minSdk = Config.minSdk
+ lint.targetSdk = Config.targetSdk
+ }
+}
+
+dependencies {
+ api(project(":mail-common:dagger"))
+ api(project(":mail-common:data"))
+ api(project(":mail-common:domain"))
+ api(project(":mail-common:presentation"))
+}
diff --git a/mail-common/dagger/build.gradle.kts b/mail-common/dagger/build.gradle.kts
new file mode 100644
index 0000000000..8a42a81d72
--- /dev/null
+++ b/mail-common/dagger/build.gradle.kts
@@ -0,0 +1,54 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ kotlin("kapt")
+ id("dagger.hilt.android.plugin")
+}
+
+android {
+ namespace = "ch.protonmail.android.mailcommon.dagger"
+ compileSdk = Config.compileSdk
+
+ defaultConfig {
+ minSdk = Config.minSdk
+ lint.targetSdk = Config.targetSdk
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+}
+
+dependencies {
+ kapt(libs.bundles.app.annotationProcessors)
+ implementation(libs.kotlin.coroutines.core)
+ implementation(libs.proton.core.label)
+ implementation(libs.dagger.hilt.android)
+
+ implementation(project(":mail-common:data"))
+ implementation(project(":mail-common:domain"))
+ implementation(project(":mail-common:presentation"))
+}
diff --git a/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/MailCommonDataModule.kt b/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/MailCommonDataModule.kt
new file mode 100644
index 0000000000..8c2c57edc7
--- /dev/null
+++ b/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/MailCommonDataModule.kt
@@ -0,0 +1,56 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.dagger
+
+import ch.protonmail.android.mailcommon.data.repository.UndoableOperationInMemoryRepository
+import ch.protonmail.android.mailcommon.data.system.BuildVersionProviderImpl
+import ch.protonmail.android.mailcommon.data.system.ContentValuesProviderImpl
+import ch.protonmail.android.mailcommon.data.system.DeviceCapabilitiesImpl
+import ch.protonmail.android.mailcommon.domain.repository.UndoableOperationRepository
+import ch.protonmail.android.mailcommon.domain.system.BuildVersionProvider
+import ch.protonmail.android.mailcommon.domain.system.ContentValuesProvider
+import ch.protonmail.android.mailcommon.domain.system.DeviceCapabilities
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module(includes = [MailCommonDataModule.BindsModule::class])
+@InstallIn(SingletonComponent::class)
+object MailCommonDataModule {
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ internal interface BindsModule {
+
+ @Binds
+ fun bindDeviceCapabilities(impl: DeviceCapabilitiesImpl): DeviceCapabilities
+
+ @Binds
+ fun bindBuildVersionProvider(impl: BuildVersionProviderImpl): BuildVersionProvider
+
+ @Binds
+ fun bindContentValuesProvider(impl: ContentValuesProviderImpl): ContentValuesProvider
+
+ @Binds
+ @Singleton
+ fun bindUndoableOperationRepository(impl: UndoableOperationInMemoryRepository): UndoableOperationRepository
+ }
+}
diff --git a/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/MailCommonModule.kt b/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/MailCommonModule.kt
new file mode 100644
index 0000000000..b971f766c3
--- /dev/null
+++ b/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/MailCommonModule.kt
@@ -0,0 +1,64 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.dagger
+
+import android.content.Context
+import ch.protonmail.android.mailcommon.data.dagger.MailCommonDataModule
+import ch.protonmail.android.mailcommon.data.repository.AppLocaleRepositoryImpl
+import ch.protonmail.android.mailcommon.domain.coroutines.AppScope
+import ch.protonmail.android.mailcommon.domain.coroutines.DefaultDispatcher
+import ch.protonmail.android.mailcommon.domain.coroutines.IODispatcher
+import ch.protonmail.android.mailcommon.domain.coroutines.MainDispatcher
+import ch.protonmail.android.mailcommon.domain.repository.AppLocaleRepository
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import javax.inject.Singleton
+
+@Module(includes = [MailCommonDataModule::class])
+@InstallIn(SingletonComponent::class)
+object MailCommonModule {
+
+ @Provides
+ @DefaultDispatcher
+ fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
+
+ @Provides
+ @IODispatcher
+ fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO
+
+ @Provides
+ @MainDispatcher
+ fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
+
+ @Provides
+ @Singleton
+ fun provideAppLocaleRepository(@ApplicationContext context: Context): AppLocaleRepository =
+ AppLocaleRepositoryImpl(context)
+
+ @Provides
+ @Singleton
+ @AppScope
+ fun provideAppScope(@DefaultDispatcher dispatcher: CoroutineDispatcher) = CoroutineScope(dispatcher)
+}
diff --git a/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/NotificationCommonModule.kt b/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/NotificationCommonModule.kt
new file mode 100644
index 0000000000..79d7047f75
--- /dev/null
+++ b/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/NotificationCommonModule.kt
@@ -0,0 +1,37 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.dagger
+
+import android.app.NotificationManager
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NotificationCommonModule {
+
+ @Provides
+ fun provideNotificationManager(@ApplicationContext context: Context): NotificationManager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+}
diff --git a/mail-common/data/build.gradle.kts b/mail-common/data/build.gradle.kts
new file mode 100644
index 0000000000..0adf2888ff
--- /dev/null
+++ b/mail-common/data/build.gradle.kts
@@ -0,0 +1,61 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ kotlin("kapt")
+ kotlin("plugin.serialization")
+}
+
+android {
+ namespace = "ch.protonmail.android.mailcommon.data"
+ compileSdk = Config.compileSdk
+
+ defaultConfig {
+ minSdk = Config.minSdk
+ lint.targetSdk = Config.targetSdk
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+}
+
+dependencies {
+ kapt(libs.bundles.app.annotationProcessors)
+
+ implementation(libs.bundles.module.data)
+ implementation(libs.androidx.appcompat)
+
+ implementation(libs.proton.core.account.data)
+ implementation(libs.proton.core.featureFlag)
+ implementation(libs.proton.core.label.data)
+ implementation(libs.proton.core.label.domain)
+ implementation(libs.proton.core.user)
+
+ implementation(project(":mail-common:domain"))
+
+ testImplementation(libs.bundles.test)
+ testImplementation(project(":test:utils"))
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/db/dao/BaseDaoExtensions.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/db/dao/BaseDaoExtensions.kt
new file mode 100644
index 0000000000..824b611f15
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/db/dao/BaseDaoExtensions.kt
@@ -0,0 +1,35 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.db.dao
+
+import arrow.core.Either
+import arrow.core.raise.either
+import ch.protonmail.android.mailcommon.domain.model.DaoError
+import me.proton.core.data.room.db.BaseDao
+import timber.log.Timber
+
+suspend fun BaseDao.upsertOrError(vararg entities: T): Either = either {
+ runCatching {
+ insertOrUpdate(*entities)
+ }.onFailure {
+ Timber.d("Error when performing upsertOrError - ${it::class.java} - ${it.message}")
+
+ raise(DaoError.UpsertError(it))
+ }
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/FileHelper.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/FileHelper.kt
new file mode 100644
index 0000000000..2562a67b22
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/FileHelper.kt
@@ -0,0 +1,189 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.file
+
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.io.OutputStream
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.withContext
+import me.proton.core.util.kotlin.DispatcherProvider
+import timber.log.Timber
+import javax.inject.Inject
+
+class FileHelper @Inject constructor(
+ private val fileStreamFactory: FileStreamFactory,
+ private val fileFactory: FileFactory,
+ private val dispatcherProvider: DispatcherProvider,
+ @ApplicationContext private val applicationContext: Context
+) {
+
+ suspend fun readFromFile(folder: Folder, filename: Filename): String? = fileOperationIn(folder) {
+ val fileToRead = fileFactory.fileFrom(folder, filename)
+ runCatching {
+ fileStreamFactory.inputStreamFrom(fileToRead)
+ .bufferedReader()
+ .use { it.readText() }
+ }.getOrNull()
+ }
+
+ suspend fun getFile(folder: Folder, filename: Filename): File? = fileOperationIn(folder) {
+ runCatching { fileFactory.fileFrom(folder, filename).takeIf { it.exists() } }.getOrNull()
+ }
+
+ suspend fun getFolder(folderName: Folder): File? = fileOperationIn(folderName) {
+ runCatching { fileFactory.folderFrom(folderName) }.getOrNull()
+ }
+
+ suspend fun renameFolder(oldFolder: Folder, newFolder: Folder) = fileOperationIn(newFolder) {
+ runCatching {
+ fileFactory.folderFromWhenExists(oldFolder)?.renameTo(fileFactory.folderFrom(newFolder)) ?: false
+ }.getOrNull()
+ } ?: false
+
+ suspend fun renameFile(
+ folder: Folder,
+ oldFilename: Filename,
+ newFilename: Filename
+ ) = fileOperationIn(folder) {
+ runCatching {
+ fileFactory.fileFromWhenExists(folder, oldFilename)
+ ?.renameTo(fileFactory.fileFrom(folder, newFilename))
+ ?: false
+ }.getOrNull()
+ } ?: false
+
+ suspend fun writeToFile(
+ folder: Folder,
+ filename: Filename,
+ content: String
+ ): Boolean = writeToFile(folder, filename, content.toByteArray()) != null
+
+ suspend fun writeToFile(
+ folder: Folder,
+ filename: Filename,
+ content: ByteArray
+ ): File? {
+ return fileOperationIn(folder) {
+ val fileToSave = fileFactory.fileFrom(folder, filename)
+ val result = runCatching { fileStreamFactory.outputStreamFrom(fileToSave).use { it.write(content) } }
+ when (result.isSuccess) {
+ true -> fileToSave
+ false -> null
+ }
+ }
+ }
+
+ suspend fun writeToFileAsStream(
+ folder: Folder,
+ filename: Filename,
+ inputStream: InputStream
+ ): File? {
+ return fileOperationIn(folder) {
+ val fileToSave = fileFactory.fileFrom(folder, filename)
+ val result = runCatching {
+ inputStream.use { input ->
+ fileStreamFactory.outputStreamFrom(fileToSave).use { output ->
+ input.copyTo(output)
+ }
+ }
+ }
+ when (result.isSuccess) {
+ true -> fileToSave
+ false -> null
+ }
+ }
+ }
+
+ suspend fun copyFile(
+ sourceFolder: Folder,
+ sourceFilename: Filename,
+ targetFolder: Folder,
+ targetFilename: Filename
+ ): File? = fileOperationIn(sourceFolder, targetFolder) {
+ runCatching {
+ fileFactory.fileFrom(sourceFolder, sourceFilename)
+ .copyTo(fileFactory.fileFrom(targetFolder, targetFilename))
+ }.getOrNull()
+ }
+
+ suspend fun deleteFile(folder: Folder, filename: Filename): Boolean = fileOperationIn(folder) {
+ runCatching {
+ fileFactory.fileFrom(folder, filename).delete()
+ }.getOrNull()
+ } ?: false
+
+ suspend fun deleteFolder(folder: Folder): Boolean = fileOperationIn(folder) {
+ runCatching {
+ fileFactory.folderFrom(folder).deleteRecursively()
+ }.getOrNull()
+ } ?: false
+
+ private suspend fun fileOperationIn(vararg folders: Folder, operation: () -> T): T? =
+ withContext(dispatcherProvider.Io) {
+ if (folders.any { it.isBlacklisted() }) {
+ Timber.w("Trying to access a blacklisted file directory: ${folders.map { it.path }}")
+ null
+ } else {
+ operation()
+ }
+ }
+
+ private fun Folder.isBlacklisted(): Boolean {
+ val internalAppFiles = applicationContext.filesDir
+ val blacklistedFolders = listOf(
+ "${internalAppFiles.parent}/databases",
+ "${internalAppFiles.parent}/shared_prefs",
+ "${internalAppFiles.path}/datastore"
+ )
+ return blacklistedFolders.any { blacklistedFolder -> path.normalised() == blacklistedFolder.normalised() }
+ }
+
+ private fun String.normalised() = File(this).normalize().path
+
+ data class Folder(val path: String)
+
+ data class Filename(val value: String)
+
+ class FileStreamFactory @Inject constructor() {
+
+ fun inputStreamFrom(file: File): InputStream = FileInputStream(file)
+
+ fun outputStreamFrom(file: File): OutputStream = FileOutputStream(file)
+ }
+
+ class FileFactory @Inject constructor() {
+
+ fun fileFrom(folder: Folder, filename: Filename) = File(
+ folderFrom(folder),
+ filename.value
+ )
+
+ fun fileFromWhenExists(folder: Folder, filename: Filename) = folderFromWhenExists(folder)?.let { dir ->
+ File(dir, filename.value).takeIf { it.exists() }
+ }
+
+ fun folderFrom(folder: Folder) = File(folder.path).apply { mkdirs() }
+
+ fun folderFromWhenExists(folder: Folder) = File(folder.path).takeIf { it.exists() }
+ }
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/IntentShareExtensions.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/IntentShareExtensions.kt
new file mode 100644
index 0000000000..9b7f5bd485
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/IntentShareExtensions.kt
@@ -0,0 +1,175 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.file
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import androidx.core.net.MailTo
+import ch.protonmail.android.mailcommon.domain.model.IntentShareInfo
+import me.proton.core.util.kotlin.takeIfNotEmpty
+import timber.log.Timber
+
+fun Intent.getShareInfo(): IntentShareInfo {
+
+ return when (action) {
+ Intent.ACTION_SEND -> getShareInfoForSingleSendAction()
+ Intent.ACTION_SEND_MULTIPLE -> getShareInfoForMultipleSendAction()
+ Intent.ACTION_VIEW -> getShareInfoForViewAction()
+ Intent.ACTION_SENDTO -> getShareInfoForSendToAction()
+
+ else -> {
+ Timber.d("Unhandled intent action: $action")
+ IntentShareInfo.Empty
+ }
+ }
+}
+
+fun Intent.isStartedFromLauncher(): Boolean = action == Intent.ACTION_MAIN
+
+private fun Intent.getShareInfoForSingleSendAction(): IntentShareInfo {
+ val fileUriList = getFileUriForActionSend()?.let {
+ listOf(it.toString())
+ } ?: emptyList()
+
+ return IntentShareInfo(
+ attachmentUris = fileUriList,
+ emailSubject = getSubject(),
+ emailRecipientTo = getRecipientTo(),
+ emailRecipientCc = getRecipientCc(),
+ emailRecipientBcc = getRecipientBcc(),
+ emailBody = getEmailBody()
+ )
+}
+
+private fun Intent.getShareInfoForMultipleSendAction(): IntentShareInfo {
+ return IntentShareInfo(
+ attachmentUris = getFileUrisForActionSendMultiple().map { it.toString() },
+ emailSubject = getSubject(),
+ emailRecipientTo = getRecipientTo(),
+ emailRecipientCc = getRecipientCc(),
+ emailRecipientBcc = getRecipientBcc(),
+ emailBody = getEmailBody()
+ )
+}
+
+private fun Intent.getShareInfoForViewAction(): IntentShareInfo {
+ val intentUri = getFileUrisForActionViewAndSendTo().takeIfNotEmpty()?.firstOrNull()
+
+ return if (intentUri?.scheme == MAILTO_SCHEME) {
+ val mailTo = MailTo.parse(intentUri)
+
+ val toRecipients = mailTo.to
+ ?.split(",")
+ ?.map { it.trim() }
+ ?: getRecipientTo()
+
+ val ccRecipients: List = mailTo.cc
+ ?.split(",")
+ ?: getRecipientCc()
+
+ val bccRecipients: List = mailTo.bcc
+ ?.split(",")
+ ?: getRecipientBcc()
+
+ val subject = mailTo.subject ?: getSubject()
+ val body = mailTo.body ?: getEmailBody()
+
+ IntentShareInfo(
+ attachmentUris = emptyList(),
+ emailSubject = subject,
+ emailRecipientTo = toRecipients,
+ emailRecipientCc = ccRecipients,
+ emailRecipientBcc = bccRecipients,
+ emailBody = body
+ )
+ } else {
+ getShareInfoForSendToAction()
+ }
+}
+
+private fun Intent.getShareInfoForSendToAction(): IntentShareInfo {
+ return IntentShareInfo(
+ attachmentUris = getFileUrisForActionViewAndSendTo().map { it.toString() },
+ emailSubject = getSubject(),
+ emailRecipientTo = getRecipientTo(),
+ emailRecipientCc = getRecipientCc(),
+ emailRecipientBcc = getRecipientBcc(),
+ emailBody = getEmailBody()
+ )
+}
+
+private fun Intent.getFileUrisForActionViewAndSendTo(): List {
+ val fileUris = mutableListOf()
+
+ data?.let { data ->
+ fileUris.add(data)
+ }
+
+ return fileUris
+}
+
+private fun Intent.getFileUriForActionSend(): Uri? {
+ val clipData = clipData
+ return if (clipData != null) {
+ clipData.getItemAt(0)?.uri
+ } else {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ @Suppress("DEPRECATION")
+ getParcelableExtra(Intent.EXTRA_STREAM)
+ } else {
+ getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
+ }
+ }
+}
+
+private fun Intent.getFileUrisForActionSendMultiple(): List {
+ val fileUris = mutableListOf()
+
+ val clipData = clipData
+ if (clipData != null) {
+ for (i in 0 until clipData.itemCount) {
+ clipData.getItemAt(i)?.uri?.run { fileUris.add(this) }
+ }
+ } else {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ @Suppress("DEPRECATION")
+ getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let { fileUris.addAll(it) }
+ } else {
+ getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.let { fileUris.addAll(it) }
+ }
+ }
+
+ return fileUris
+}
+
+private fun Intent.getSubject(): String? = getStringExtra(Intent.EXTRA_SUBJECT)
+
+private fun Intent.getRecipientTo(): List = getStringArrayExtra(Intent.EXTRA_EMAIL)?.toList() ?: emptyList()
+
+private fun Intent.getRecipientCc(): List = getStringArrayExtra(Intent.EXTRA_CC)?.toList() ?: emptyList()
+
+private fun Intent.getRecipientBcc(): List = getStringArrayExtra(Intent.EXTRA_BCC)?.toList() ?: emptyList()
+
+private fun Intent.getEmailBody(): String? = getStringExtra(Intent.EXTRA_TEXT)
+
+/**
+ * Intent data can be a [Uri] with a mailto scheme instead of a shared file.
+ */
+private const val MAILTO_SCHEME = "mailto"
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/InternalFileStorage.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/InternalFileStorage.kt
new file mode 100644
index 0000000000..68f8c0f208
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/InternalFileStorage.kt
@@ -0,0 +1,221 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.file
+
+import java.io.File
+import java.io.InputStream
+import android.content.Context
+import ch.protonmail.android.mailcommon.domain.coroutines.IODispatcher
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import me.proton.core.domain.entity.UserId
+import me.proton.core.util.kotlin.HashUtils
+import javax.inject.Inject
+
+class InternalFileStorage @Inject constructor(
+ @ApplicationContext private val applicationContext: Context,
+ private val fileHelper: FileHelper,
+ @IODispatcher private val ioDispatcher: CoroutineDispatcher
+) {
+
+ suspend fun readFromFile(
+ userId: UserId,
+ folder: Folder,
+ fileIdentifier: FileIdentifier
+ ): String? = fileHelper.readFromFile(
+ folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"),
+ filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath())
+ )
+
+ suspend fun readFromCachedFile(
+ userId: UserId,
+ folder: Folder,
+ fileIdentifier: FileIdentifier
+ ): String? = fileHelper.readFromFile(
+ folder = FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}"),
+ filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath())
+ )
+
+ suspend fun renameFolder(
+ userId: UserId,
+ oldFolder: Folder,
+ newFolder: Folder
+ ) = fileHelper.renameFolder(
+ oldFolder = FileHelper.Folder("${userId.asRootDirectory()}${oldFolder.path}"),
+ newFolder = FileHelper.Folder("${userId.asRootDirectory()}${newFolder.path}")
+ )
+
+ suspend fun renameFile(
+ userId: UserId,
+ folder: Folder,
+ oldFileIdentifier: FileIdentifier,
+ newFileIdentifier: FileIdentifier
+ ) = fileHelper.renameFile(
+ folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"),
+ oldFilename = FileHelper.Filename(oldFileIdentifier.value.asSanitisedPath()),
+ newFilename = FileHelper.Filename(newFileIdentifier.value.asSanitisedPath())
+ )
+
+ suspend fun getFile(
+ userId: UserId,
+ folder: Folder,
+ fileIdentifier: FileIdentifier
+ ): File? = fileHelper.getFile(
+ folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"),
+ filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath())
+ )
+
+ suspend fun getCachedFile(
+ userId: UserId,
+ folder: Folder,
+ fileIdentifier: FileIdentifier
+ ): File? = fileHelper.getFile(
+ folder = FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}"),
+ filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath())
+ )
+
+ suspend fun getFolder(userId: UserId, folder: Folder): File? =
+ fileHelper.getFolder(FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}"))
+
+ suspend fun writeToFile(
+ userId: UserId,
+ folder: Folder,
+ fileIdentifier: FileIdentifier,
+ content: String
+ ): Boolean = fileHelper.writeToFile(
+ folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"),
+ filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()),
+ content = content
+ )
+
+ suspend fun writeToCachedFile(
+ userId: UserId,
+ folder: Folder,
+ fileIdentifier: FileIdentifier,
+ content: String
+ ): Boolean = fileHelper.writeToFile(
+ folder = FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}"),
+ filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()),
+ content = content
+ )
+
+
+ suspend fun writeFile(
+ userId: UserId,
+ folder: Folder,
+ fileIdentifier: FileIdentifier,
+ content: ByteArray
+ ): File? = fileHelper.writeToFile(
+ folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"),
+ filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()),
+ content = content
+ )
+
+ suspend fun writeCachedFile(
+ userId: UserId,
+ folder: Folder,
+ fileIdentifier: FileIdentifier,
+ content: ByteArray
+ ): File? = fileHelper.writeToFile(
+ folder = FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}"),
+ filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()),
+ content = content
+ )
+
+ suspend fun writeFileAsStream(
+ userId: UserId,
+ folder: Folder,
+ fileIdentifier: FileIdentifier,
+ inputStream: InputStream
+ ): File? = fileHelper.writeToFileAsStream(
+ folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"),
+ filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()),
+ inputStream = inputStream
+ )
+
+ suspend fun deleteFile(
+ userId: UserId,
+ folder: Folder,
+ fileIdentifier: FileIdentifier
+ ): Boolean = fileHelper.deleteFile(
+ folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"),
+ filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath())
+ )
+
+ suspend fun deleteCachedFile(
+ userId: UserId,
+ folder: Folder,
+ fileIdentifier: FileIdentifier
+ ): Boolean = fileHelper.deleteFile(
+ folder = FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}"),
+ filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath())
+ )
+
+ suspend fun deleteFolder(userId: UserId, folder: Folder): Boolean = fileHelper.deleteFolder(
+ folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}")
+ )
+
+ suspend fun deleteCachedFolder(userId: UserId, folder: Folder): Boolean = fileHelper.deleteFolder(
+ folder = FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}")
+ )
+
+ suspend fun copyCachedFileToNonCachedFolder(
+ userId: UserId,
+ sourceFolder: Folder,
+ sourceFileIdentifier: FileIdentifier,
+ targetFolder: Folder,
+ targetFileIdentifier: FileIdentifier
+ ): File? = fileHelper.copyFile(
+ sourceFolder = FileHelper.Folder("${userId.asRootCacheDirectory()}${sourceFolder.path}"),
+ sourceFilename = FileHelper.Filename(sourceFileIdentifier.value.asSanitisedPath()),
+ targetFolder = FileHelper.Folder("${userId.asRootDirectory()}${targetFolder.path}"),
+ targetFilename = FileHelper.Filename(targetFileIdentifier.value.asSanitisedPath())
+ )
+
+ private suspend fun UserId.asRootDirectory() = withContext(ioDispatcher) {
+ "${applicationContext.filesDir}/${id.asSanitisedPath()}/"
+ }
+
+ private suspend fun UserId.asRootCacheDirectory() = withContext(ioDispatcher) {
+ "${applicationContext.cacheDir}/${id.asSanitisedPath()}/"
+ }
+
+ private fun String.asSanitisedPath() = HashUtils.sha256(this)
+
+ @JvmInline
+ value class FileIdentifier(val value: String)
+
+ sealed class Folder(open val path: String) {
+ object MessageBodies : Folder("message_bodies/")
+ object MessageAttachmentsRoot : Folder("attachments/")
+ data class MessageAttachments(val messageId: String) : Folder("${MessageAttachmentsRoot.path}$messageId/")
+ }
+}
+
+/**
+ * See https://www.sqlite.org/intern-v-extern-blob.html
+ *
+ * The article compares the performance of storing large blobs in db directly vs storing them as files on disk.
+ * It does not directly relate to Android, and no actual testing was done on Android from our side.
+ * The assumption is that the performance findings will roughly translate to Android nevertheless and the cut-off
+ * threshold of 500kB is chosen based on the measurements from the article.
+ */
+@Suppress("MagicNumber")
+fun String.shouldBeStoredAsFile() = this.toByteArray().size > 500 * 1024
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/UriHelper.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/UriHelper.kt
new file mode 100644
index 0000000000..508d295c4b
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/UriHelper.kt
@@ -0,0 +1,87 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.file
+
+import java.io.InputStream
+import android.content.Context
+import android.net.Uri
+import android.provider.OpenableColumns
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.withContext
+import me.proton.core.util.kotlin.DispatcherProvider
+import javax.inject.Inject
+
+class UriHelper @Inject constructor(
+ private val dispatcherProvider: DispatcherProvider,
+ private val contentResolverHelper: ContentResolverHelper
+) {
+
+ suspend fun readFromUri(uri: Uri): InputStream? = withContext(dispatcherProvider.Io) {
+ runCatching { contentResolverHelper.openInputStream(uri) }.getOrNull()
+ }
+
+ suspend fun getFileInformationFromUri(uri: Uri): FileInformation? {
+ val name = getFileNameFromUri(uri)
+ val size = getFileSizeFromUri(uri)
+ val mimeType = getFileMimeTypeFromUri(uri)
+
+ if (name.isNullOrEmpty() || size == null || mimeType.isNullOrEmpty()) return null
+
+ return FileInformation(name, size, mimeType)
+ }
+
+ private suspend fun getFileNameFromUri(uri: Uri) = withContext(dispatcherProvider.Io) {
+ contentResolverHelper.query(uri)
+ ?.use { cursor ->
+ val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ cursor.moveToFirst()
+ cursor.getString(nameIndex)
+ }
+ }
+
+ private suspend fun getFileSizeFromUri(uri: Uri) = withContext(dispatcherProvider.Io) {
+ contentResolverHelper.query(uri)
+ ?.use { cursor ->
+ val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
+ cursor.moveToFirst()
+ cursor.getLong(sizeIndex)
+ }
+ }
+
+ private suspend fun getFileMimeTypeFromUri(uri: Uri) = withContext(dispatcherProvider.Io) {
+ contentResolverHelper.getType(uri)
+ }
+}
+
+class ContentResolverHelper @Inject constructor(
+ @ApplicationContext private val applicationContext: Context
+) {
+
+ fun openInputStream(uri: Uri) = applicationContext.contentResolver.openInputStream(uri)
+
+ fun getType(uri: Uri) = applicationContext.contentResolver.getType(uri)
+
+ fun query(uri: Uri) = applicationContext.contentResolver.query(uri, null, null, null, null)
+}
+
+data class FileInformation(
+ val name: String,
+ val size: Long,
+ val mimeType: String
+)
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/mapper/ApiResultEitherMapping.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/mapper/ApiResultEitherMapping.kt
new file mode 100644
index 0000000000..a50dea1afc
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/mapper/ApiResultEitherMapping.kt
@@ -0,0 +1,89 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.mapper
+
+import java.net.UnknownHostException
+import arrow.core.Either
+import arrow.core.left
+import arrow.core.right
+import ch.protonmail.android.mailcommon.domain.mapper.fromHttpCode
+import ch.protonmail.android.mailcommon.domain.mapper.fromProtonCode
+import ch.protonmail.android.mailcommon.domain.model.DataError
+import ch.protonmail.android.mailcommon.domain.model.NetworkError
+import ch.protonmail.android.mailcommon.domain.model.ProtonError
+import me.proton.core.network.domain.ApiResult
+import me.proton.core.network.domain.isRetryable
+import timber.log.Timber
+
+fun ApiResult.toEither(): Either = when (this) {
+ is ApiResult.Success -> value.right()
+
+ is ApiResult.Error.Http -> {
+ when {
+ isMessageAlreadySentDraftError() -> DataError.Remote.Proton(ProtonError.MessageUpdateDraftNotDraft).left()
+ isMessageAlreadySentAttachmentError() ->
+ DataError.Remote.Proton(ProtonError.AttachmentUploadMessageAlreadySent).left()
+ isMessageAlreadySentSendingError() -> DataError.Remote.Proton(ProtonError.MessageAlreadySent).left()
+ isSearchInputInvalidError() -> DataError.Remote.Proton(ProtonError.SearchInputInvalid).left()
+ else -> DataError.Remote.Http(
+ NetworkError.fromHttpCode(httpCode),
+ this.extractApiErrorInfo(),
+ this.isRetryable()
+ ).left()
+ }
+ }
+
+ is ApiResult.Error.Parse -> {
+ Timber.e("Unexpected parse error, caused by: ${this.cause}")
+ DataError.Remote.Http(NetworkError.Parse, this.cause.tryExtractError(), this.isRetryable()).left()
+ }
+
+ is ApiResult.Error.Connection -> {
+ DataError.Remote.Http(toNetworkError(this), this.cause.tryExtractError(), this.isRetryable()).left()
+ }
+}
+
+private fun ApiResult.Error.Http.isMessageAlreadySentDraftError() =
+ NetworkError.fromHttpCode(this.httpCode) == NetworkError.UnprocessableEntity &&
+ ProtonError.fromProtonCode(this.proton?.code) == ProtonError.MessageUpdateDraftNotDraft
+
+private fun ApiResult.Error.Http.isMessageAlreadySentAttachmentError() =
+ NetworkError.fromHttpCode(this.httpCode) == NetworkError.UnprocessableEntity &&
+ ProtonError.fromProtonCode(this.proton?.code) == ProtonError.AttachmentUploadMessageAlreadySent
+
+private fun ApiResult.Error.Http.isMessageAlreadySentSendingError() =
+ NetworkError.fromHttpCode(this.httpCode) == NetworkError.UnprocessableEntity &&
+ ProtonError.fromProtonCode(this.proton?.code) == ProtonError.MessageAlreadySent
+
+private fun ApiResult.Error.Http.isSearchInputInvalidError() =
+ NetworkError.fromHttpCode(this.httpCode) == NetworkError.UnprocessableEntity &&
+ ProtonError.fromProtonCode(this.proton?.code) == ProtonError.SearchInputInvalid
+
+private fun Throwable?.tryExtractError() = this?.cause?.message ?: "No error message found"
+
+private fun ApiResult.Error.Http.extractApiErrorInfo(): String = "${this.message} - ${this.proton?.error}"
+
+private fun toNetworkError(apiResult: ApiResult.Error.Connection): NetworkError = when (apiResult) {
+ is ApiResult.Error.NoInternet -> NetworkError.NoNetwork
+ else -> if (apiResult.cause is UnknownHostException) {
+ NetworkError.NoNetwork
+ } else {
+ NetworkError.Unreachable
+ }
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/mapper/DataStoreEitherMappings.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/mapper/DataStoreEitherMappings.kt
new file mode 100644
index 0000000000..803ea8c561
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/mapper/DataStoreEitherMappings.kt
@@ -0,0 +1,53 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.mapper
+
+import java.io.IOException
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.MutablePreferences
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import arrow.core.Either
+import arrow.core.left
+import arrow.core.right
+import ch.protonmail.android.mailcommon.domain.model.PreferencesError
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import timber.log.Timber
+
+val DataStore.safeData: Flow>
+ // type inference fails to resolve type for `Preferences.right() as Either`
+ @Suppress("USELESS_CAST")
+ get() = data.map { it.right() as Either }
+ .catch { throwable ->
+ if (throwable is IOException) {
+ Timber.e(throwable, "Error reading preference")
+ emit(PreferencesError.left())
+ } else throw throwable
+ }
+
+suspend fun DataStore.safeEdit(
+ transform: suspend (MutablePreferences) -> Unit
+): Either = try {
+ edit(transform).right()
+} catch (exception: IOException) {
+ Timber.e(exception, "Error saving preference")
+ PreferencesError.left()
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/repository/AppLocaleRepositoryImpl.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/repository/AppLocaleRepositoryImpl.kt
new file mode 100644
index 0000000000..496efcb138
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/repository/AppLocaleRepositoryImpl.kt
@@ -0,0 +1,66 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.repository
+
+import java.util.Locale
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import androidx.appcompat.app.AppCompatDelegate
+import ch.protonmail.android.mailcommon.domain.repository.AppLocaleRepository
+
+class AppLocaleRepositoryImpl(val context: Context) : AppLocaleRepository, BroadcastReceiver() {
+
+ private var savedLocale: Locale? = null
+
+ init {
+ val localeBroadcastFilter = IntentFilter().apply {
+ addAction(Intent.ACTION_LOCALE_CHANGED)
+ addAction(Intent.ACTION_TIMEZONE_CHANGED)
+ }
+
+ context.registerReceiver(this, localeBroadcastFilter)
+ }
+
+ override fun current(): Locale {
+ // AppCompatDelegate.getApplicationLocales makes IPC call to Locale Service on Android 13 and above.
+ // So it's better to cache the result
+ if (savedLocale == null) {
+ savedLocale = obtainCurrentLocale()
+ }
+
+ return savedLocale ?: Locale.getDefault() // Use default Locale if savedLocale is null
+ }
+
+ override fun refresh() {
+ savedLocale = obtainCurrentLocale()
+ }
+
+ private fun obtainCurrentLocale(): Locale {
+ val savedAppLocales = AppCompatDelegate.getApplicationLocales()
+ val languageTag = savedAppLocales[0]?.toLanguageTag() ?: Locale.getDefault().toLanguageTag()
+ return Locale.forLanguageTag(languageTag)
+ }
+
+ // Refresh saved locale when app locale changes
+ override fun onReceive(context: Context?, intent: Intent?) {
+ refresh()
+ }
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/repository/UndoableOperationInMemoryRepository.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/repository/UndoableOperationInMemoryRepository.kt
new file mode 100644
index 0000000000..9d22314525
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/repository/UndoableOperationInMemoryRepository.kt
@@ -0,0 +1,37 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.repository
+
+import ch.protonmail.android.mailcommon.domain.model.UndoableOperation
+import ch.protonmail.android.mailcommon.domain.repository.UndoableOperationRepository
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class UndoableOperationInMemoryRepository @Inject constructor() : UndoableOperationRepository {
+
+ private var operation: UndoableOperation? = null
+
+ override suspend fun storeOperation(operation: UndoableOperation) {
+ this.operation = operation
+ }
+
+ override suspend fun getLastOperation(): UndoableOperation? = operation
+
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/AccountEntitySample.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/AccountEntitySample.kt
new file mode 100644
index 0000000000..f954d4d4ac
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/AccountEntitySample.kt
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.sample
+
+import ch.protonmail.android.mailcommon.domain.sample.AccountSample
+import me.proton.core.account.data.entity.AccountEntity
+import me.proton.core.account.domain.entity.Account
+
+object AccountEntitySample {
+
+ val Primary = build(AccountSample.Primary)
+
+ val PrimaryNotReady = build(AccountSample.PrimaryNotReady)
+
+ fun build(account: Account = AccountSample.build()) = AccountEntity(
+ email = account.email,
+ sessionId = account.sessionId,
+ sessionState = account.sessionState,
+ state = account.state,
+ userId = account.userId,
+ username = account.username
+ )
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/AddressEntitySample.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/AddressEntitySample.kt
new file mode 100644
index 0000000000..716f754c90
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/AddressEntitySample.kt
@@ -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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.sample
+
+import ch.protonmail.android.mailcommon.domain.sample.AddressIdSample
+import ch.protonmail.android.mailcommon.domain.sample.UserIdSample
+import me.proton.core.user.data.entity.AddressEntity
+
+object AddressEntitySample {
+
+ val Primary = AddressEntity(
+ addressId = AddressIdSample.Primary,
+ canReceive = true,
+ canSend = true,
+ email = "primary@proton.me",
+ enabled = true,
+ order = 1,
+ signedKeyList = null,
+ type = 1,
+ userId = UserIdSample.Primary
+ )
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/LabelEntitySample.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/LabelEntitySample.kt
new file mode 100644
index 0000000000..295fb74210
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/LabelEntitySample.kt
@@ -0,0 +1,43 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.sample
+
+import ch.protonmail.android.mailcommon.domain.sample.LabelSample
+import me.proton.core.label.data.local.LabelEntity
+import me.proton.core.label.domain.entity.Label
+
+object LabelEntitySample {
+
+ val Archive = build(LabelSample.Archive)
+ val Document = build(LabelSample.Document)
+
+ fun build(label: Label = LabelSample.build()) = LabelEntity(
+ color = label.color,
+ isExpanded = label.isExpanded,
+ isNotified = label.isNotified,
+ isSticky = label.isSticky,
+ labelId = label.labelId,
+ name = label.name,
+ order = label.order,
+ parentId = label.parentId?.id,
+ path = label.path,
+ type = label.type.value,
+ userId = label.userId
+ )
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/SessionEntitySample.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/SessionEntitySample.kt
new file mode 100644
index 0000000000..c3b4270629
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/SessionEntitySample.kt
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.sample
+
+import ch.protonmail.android.mailcommon.domain.sample.SessionSample
+import ch.protonmail.android.mailcommon.domain.sample.UserIdSample
+import me.proton.core.account.data.entity.SessionEntity
+import me.proton.core.domain.entity.Product
+import me.proton.core.network.domain.session.Session
+
+object SessionEntitySample {
+
+ val Primary = build(SessionSample.Primary)
+
+ fun build(session: Session = SessionSample.build()) = SessionEntity(
+ accessToken = session.accessToken,
+ product = Product.Mail,
+ refreshToken = session.refreshToken,
+ scopes = session.scopes,
+ sessionId = session.sessionId,
+ userId = UserIdSample.Primary
+ )
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/UserEntitySample.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/UserEntitySample.kt
new file mode 100644
index 0000000000..9f57396649
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/UserEntitySample.kt
@@ -0,0 +1,54 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.sample
+
+import ch.protonmail.android.mailcommon.domain.sample.UserSample
+import me.proton.core.user.data.entity.UserEntity
+import me.proton.core.user.domain.entity.User
+
+object UserEntitySample {
+
+ val Primary = build(UserSample.Primary)
+
+ fun build(user: User = UserSample.build()) = UserEntity(
+ type = 0,
+ credit = user.credit,
+ createdAtUtc = user.createdAtUtc,
+ currency = user.currency,
+ delinquent = user.delinquent?.value,
+ displayName = user.displayName,
+ email = user.email,
+ isPrivate = user.private,
+ maxSpace = user.maxSpace,
+ maxUpload = user.maxUpload,
+ name = user.name,
+ passphrase = null,
+ role = user.role?.value,
+ services = user.services,
+ subscribed = user.subscribed,
+ usedSpace = user.usedSpace,
+ userId = user.userId,
+ recovery = null,
+ maxBaseSpace = null,
+ maxDriveSpace = null,
+ usedBaseSpace = null,
+ usedDriveSpace = null,
+ flags = null
+ )
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/BuildVersionProviderImpl.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/BuildVersionProviderImpl.kt
new file mode 100644
index 0000000000..2ac52cf89b
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/BuildVersionProviderImpl.kt
@@ -0,0 +1,28 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.system
+
+import android.os.Build
+import ch.protonmail.android.mailcommon.domain.system.BuildVersionProvider
+import javax.inject.Inject
+
+class BuildVersionProviderImpl @Inject constructor() : BuildVersionProvider {
+
+ override fun sdkInt() = Build.VERSION.SDK_INT
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/ContentValuesProviderImpl.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/ContentValuesProviderImpl.kt
new file mode 100644
index 0000000000..2b39c3d6f3
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/ContentValuesProviderImpl.kt
@@ -0,0 +1,28 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.system
+
+import android.content.ContentValues
+import ch.protonmail.android.mailcommon.domain.system.ContentValuesProvider
+import javax.inject.Inject
+
+class ContentValuesProviderImpl @Inject constructor() : ContentValuesProvider {
+
+ override fun provideContentValues() = ContentValues()
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/DeviceCapabilitiesImpl.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/DeviceCapabilitiesImpl.kt
new file mode 100644
index 0000000000..424d6deac6
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/DeviceCapabilitiesImpl.kt
@@ -0,0 +1,33 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.system
+
+import android.webkit.WebView
+import ch.protonmail.android.mailcommon.domain.system.DeviceCapabilities
+import javax.inject.Inject
+
+class DeviceCapabilitiesImpl @Inject constructor() : DeviceCapabilities {
+
+ // No need for custom getters as per official documentation:
+ // "If the WebView package changes, any app process that has loaded WebView will be killed."
+ // See `WebView.getCurrentWebViewPackage()` docs for more info.
+ override fun getCapabilities(): DeviceCapabilities.Capabilities = DeviceCapabilities.Capabilities(
+ hasWebView = WebView.getCurrentWebViewPackage()?.applicationInfo?.enabled ?: false
+ )
+}
diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/worker/Enqueuer.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/worker/Enqueuer.kt
new file mode 100644
index 0000000000..f7d4b42d9b
--- /dev/null
+++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/worker/Enqueuer.kt
@@ -0,0 +1,208 @@
+/*
+ * 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 .
+ */
+
+package ch.protonmail.android.mailcommon.data.worker
+
+import java.util.concurrent.TimeUnit
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.ExistingWorkPolicy
+import androidx.work.ListenableWorker
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import me.proton.core.domain.entity.UserId
+import javax.inject.Inject
+
+class Enqueuer @Inject constructor(private val workManager: WorkManager) {
+
+ inline fun enqueue(userId: UserId, params: Map) {
+ enqueue(userId, T::class.java, params)
+ }
+
+ inline fun enqueueUniqueWork(
+ userId: UserId,
+ workerId: String,
+ params: Map,
+ constraints: Constraints? = buildDefaultConstraints(),
+ existingWorkPolicy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP
+ ) {
+ enqueueUniqueWork(userId, workerId, T::class.java, params, constraints, existingWorkPolicy)
+ }
+
+ inline fun enqueueInChain(
+ userId: UserId,
+ uniqueWorkId: String,
+ params1: Map,
+ params2: Map,
+ constraints: Constraints = buildDefaultConstraints(),
+ existingWorkPolicy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP
+ ) {
+ enqueueInChain(
+ userId,
+ uniqueWorkId,
+ T::class.java,
+ params1,
+ K::class.java,
+ params2,
+ constraints,
+ existingWorkPolicy
+ )
+ }
+
+ inline fun <
+ reified T : ListenableWorker,
+ reified K : ListenableWorker,
+ reified R : ListenableWorker
+ > enqueueInChain(
+ userId: UserId,
+ uniqueWorkId: String,
+ params1: Map,
+ params2: Map,
+ params3: Map,
+ constraints: Constraints = buildDefaultConstraints(),
+ existingWorkPolicy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP
+ ) {
+ enqueueInChain(
+ userId,
+ uniqueWorkId,
+ T::class.java,
+ params1,
+ K::class.java,
+ params2,
+ R::class.java,
+ params3,
+ constraints,
+ existingWorkPolicy
+ )
+ }
+
+ fun enqueue(
+ userId: UserId,
+ worker: Class,
+ params: Map
+ ) {
+ workManager.enqueue(createRequest(userId, worker, null, params, buildDefaultConstraints()))
+ }
+
+ @Suppress("LongParameterList")
+ fun enqueueInChain(
+ userId: UserId,
+ uniqueWorkId: String,
+ worker1: Class,
+ params1: Map,
+ worker2: Class,
+ params2: Map,
+ constraints: Constraints,
+ existingWorkPolicy: ExistingWorkPolicy
+ ) {
+ workManager.beginUniqueWork(
+ uniqueWorkId,
+ existingWorkPolicy,
+ createRequest(userId, worker1, uniqueWorkId, params1, constraints)
+ )
+ .then(createRequest(userId, worker2, uniqueWorkId, params2, constraints))
+ .enqueue()
+ }
+
+ @Suppress("LongParameterList")
+ fun enqueueInChain(
+ userId: UserId,
+ uniqueWorkId: String,
+ worker1: Class