diff --git a/FlowCrypt/build.gradle b/FlowCrypt/build.gradle index c906d5705e..2f581a3928 100644 --- a/FlowCrypt/build.gradle +++ b/FlowCrypt/build.gradle @@ -393,12 +393,10 @@ dependencies { implementation 'androidx.navigation:navigation-runtime-ktx:2.5.0' implementation 'androidx.webkit:webkit:1.4.0' - //https://developers.google.com/android/guides/setup implementation 'com.google.android.gms:play-services-base:18.1.0' implementation 'com.google.android.gms:play-services-auth:20.2.0' - - //https://mvnrepository.com/artifact/com.google.android.material/material implementation 'com.google.android.material:material:1.6.1' + implementation 'com.google.android.flexbox:flexbox:3.0.0' //https://mvnrepository.com/artifact/com.google.code.gson/gson implementation 'com.google.code.gson:gson:2.9.0' @@ -421,7 +419,6 @@ dependencies { } implementation 'com.github.bumptech.glide:glide:4.13.2' - implementation 'com.hootsuite.android:nachos:1.2.0' implementation 'com.nulab-inc:zxcvbn:1.7.0' implementation 'commons-io:commons-io:2.11.0' implementation 'ja.burhanrashid52:photoeditor:1.1.1' diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/ChipBackgroundColorMatcher.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/ChipBackgroundColorMatcher.kt new file mode 100644 index 0000000000..9318b09b37 --- /dev/null +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/ChipBackgroundColorMatcher.kt @@ -0,0 +1,29 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.matchers + +import android.view.View +import androidx.test.espresso.matcher.BoundedMatcher +import com.google.android.material.chip.Chip +import org.hamcrest.Description + +/** + * @author Denis Bondarenko + * Date: 4/22/21 + * Time: 10:19 AM + * E-mail: DenBond7@gmail.com + */ +class ChipBackgroundColorMatcher( + private val color: Int +) : BoundedMatcher(Chip::class.java) { + public override fun matchesSafely(chip: Chip): Boolean { + return color == chip.chipBackgroundColor?.defaultColor + } + + override fun describeTo(description: Description) { + description.appendText("Chip details: color = $color") + } +} diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/ChipCloseIconAvailabilityMatcher.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/ChipCloseIconAvailabilityMatcher.kt new file mode 100644 index 0000000000..262cca6bd8 --- /dev/null +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/ChipCloseIconAvailabilityMatcher.kt @@ -0,0 +1,23 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: denbond7 + */ + +package com.flowcrypt.email.matchers + +import android.view.View +import androidx.test.espresso.matcher.BoundedMatcher +import com.google.android.material.chip.Chip +import org.hamcrest.Description + +class ChipCloseIconAvailabilityMatcher( + private val isCloseIconVisible: Boolean +) : BoundedMatcher(Chip::class.java) { + public override fun matchesSafely(chip: Chip): Boolean { + return chip.isCloseIconVisible == isCloseIconVisible + } + + override fun describeTo(description: Description) { + description.appendText("Chip details: isCloseIconVisible = $isCloseIconVisible") + } +} diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/CustomMatchers.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/CustomMatchers.kt index fdd8d956ac..d0a7c05877 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/CustomMatchers.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/CustomMatchers.kt @@ -7,7 +7,6 @@ package com.flowcrypt.email.matchers import android.content.Context import android.view.View -import android.widget.ListView import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat @@ -16,9 +15,8 @@ import androidx.test.espresso.Root import androidx.test.espresso.matcher.BoundedMatcher import com.flowcrypt.email.api.email.model.SecurityType import com.flowcrypt.email.ui.adapter.PgpBadgeListAdapter -import com.flowcrypt.email.ui.widget.PGPContactChipSpan import com.google.android.material.appbar.AppBarLayout -import com.hootsuite.nachos.NachoTextView +import com.google.android.material.chip.Chip import org.hamcrest.BaseMatcher import org.hamcrest.Matcher @@ -67,14 +65,6 @@ class CustomMatchers { return AppBarLayoutBackgroundColorMatcher(color) } - /** - * Match is [ListView] empty. - */ - @JvmStatic - fun withEmptyListView(): BaseMatcher { - return EmptyListViewMather() - } - /** * Match is [androidx.recyclerview.widget.RecyclerView] empty. */ @@ -83,14 +73,6 @@ class CustomMatchers { return EmptyRecyclerViewMatcher() } - /** - * Match is an items count of [ListView] empty. - */ - @JvmStatic - fun withListViewItemCount(itemCount: Int): BaseMatcher { - return ListViewItemCountMatcher(itemCount) - } - /** * Match is an items count of [RecyclerView] empty. */ @@ -99,17 +81,12 @@ class CustomMatchers { return RecyclerViewItemCountMatcher(itemCount) } - /** - * Match a color of the given [PGPContactChipSpan] in [NachoTextView]. - * - * @param chipText The given chip text. - * @param backgroundColor The given chip background color. - * @return true if matched, otherwise false - */ - @JvmStatic - fun withChipsBackgroundColor(chipText: String, backgroundColor: Int): - BoundedMatcher { - return NachoTextViewChipBackgroundColorMatcher(chipText, backgroundColor) + fun withChipsBackgroundColor(context: Context, resourceId: Int): BoundedMatcher { + return ChipBackgroundColorMatcher(ContextCompat.getColor(context, resourceId)) + } + + fun withChipCloseIconAvailability(isCloseIconVisible: Boolean): BoundedMatcher { + return ChipCloseIconAvailabilityMatcher(isCloseIconVisible) } fun withPgpBadge(pgpBadge: PgpBadgeListAdapter.PgpBadge): PgpBadgeMatcher { @@ -127,6 +104,10 @@ class CustomMatchers { return TextViewDrawableMatcher(resourceId, drawablePosition) } + fun hasItem(matcher: Matcher): Matcher { + return RecyclerViewItemMatcher(matcher) + } + fun withTextViewBackgroundTint( context: Context, @ColorRes resourceId: Int diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/NachoTextViewChipBackgroundColorMatcher.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/NachoTextViewChipBackgroundColorMatcher.kt deleted file mode 100644 index 55e8d3c52d..0000000000 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/NachoTextViewChipBackgroundColorMatcher.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 - */ - -package com.flowcrypt.email.matchers - -import android.view.View -import androidx.test.espresso.matcher.BoundedMatcher -import com.flowcrypt.email.ui.widget.PGPContactChipSpan -import com.hootsuite.nachos.NachoTextView -import org.hamcrest.Description - -/** - * @author Denis Bondarenko - * Date: 4/22/21 - * Time: 10:19 AM - * E-mail: DenBond7@gmail.com - */ -class NachoTextViewChipBackgroundColorMatcher( - private val chipText: String, - private val backgroundColor: Int -) : BoundedMatcher(NachoTextView::class.java) { - public override fun matchesSafely(nachoTextView: NachoTextView): Boolean { - val expectedChip = nachoTextView.allChips.firstOrNull { it.text == chipText } ?: return false - val pgpContactChipSpan = expectedChip as? PGPContactChipSpan ?: return false - return backgroundColor == pgpContactChipSpan.chipBackgroundColor?.defaultColor - } - - override fun describeTo(description: Description) { - description.appendText("Chip details: text = $chipText, color = $backgroundColor") - } -} diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/RecyclerViewItemMatcher.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/RecyclerViewItemMatcher.kt new file mode 100644 index 0000000000..43db7779da --- /dev/null +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/RecyclerViewItemMatcher.kt @@ -0,0 +1,36 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.matchers + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.matcher.BoundedMatcher +import org.hamcrest.Description +import org.hamcrest.Matcher + +/** + * @author Denis Bondarenko + * Date: 7/29/22 + * Time: 1:20 PM + * E-mail: DenBond7@gmail.com + */ +class RecyclerViewItemMatcher(val matcher: Matcher) : + BoundedMatcher(RecyclerView::class.java) { + public override fun matchesSafely(recyclerView: RecyclerView): Boolean { + val adapter = recyclerView.adapter ?: throw IllegalStateException("adapter is not present") + for (position in 0 until adapter.itemCount) { + val type = adapter.getItemViewType(position) + val holder = adapter.createViewHolder(recyclerView, type) + adapter.onBindViewHolder(holder, position) + if (matcher.matches(holder.itemView)) { + return true + } + } + return false + } + + override fun describeTo(description: Description) {} +} diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenDisallowUpdateRevokedKeyFlowTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenDisallowUpdateRevokedKeyFlowTest.kt index 971eb5661f..fc824283a9 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenDisallowUpdateRevokedKeyFlowTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenDisallowUpdateRevokedKeyFlowTest.kt @@ -5,10 +5,12 @@ package com.flowcrypt.email.ui +import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.scrollTo import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.activityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest @@ -28,14 +30,15 @@ import com.flowcrypt.email.rules.RetryRule import com.flowcrypt.email.rules.ScreenshotTestRule import com.flowcrypt.email.security.pgp.PgpKey import com.flowcrypt.email.ui.activity.CreateMessageActivity +import com.flowcrypt.email.ui.adapter.RecipientChipRecyclerViewAdapter import com.flowcrypt.email.ui.base.BaseComposeScreenTest import com.flowcrypt.email.util.AccountDaoManager import com.flowcrypt.email.util.PrivateKeysManager import com.flowcrypt.email.util.TestGeneralUtil -import com.flowcrypt.email.util.UIUtil import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.RecordedRequest +import org.hamcrest.Matchers.allOf import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.ClassRule @@ -107,12 +110,15 @@ class ComposeScreenDisallowUpdateRevokedKeyFlowTest : BaseComposeScreenTest() { fillInAllFields(userWithRevokedKey) //check that UI shows a revoked key after call lookupEmail - onView(withId(R.id.editTextRecipientTo)) - .check( - matches( - withChipsBackgroundColor( - userWithRevokedKey, - UIUtil.getColor(getTargetContext(), R.color.red) + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + scrollTo( + allOf( + withText(userWithRevokedKey), + withChipsBackgroundColor( + getTargetContext(), + RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_HAS_PUB_KEY_BUT_REVOKED + ) ) ) ) diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenExternalIntentsFlowTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenExternalIntentsFlowTest.kt index c65dddf08b..491c32376e 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenExternalIntentsFlowTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenExternalIntentsFlowTest.kt @@ -9,11 +9,13 @@ import android.content.Intent import android.net.Uri import androidx.core.content.FileProvider import androidx.core.net.MailTo +import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.closeSoftKeyboard import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.scrollTo import androidx.test.espresso.matcher.ViewMatchers.hasChildCount import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId @@ -24,6 +26,7 @@ import com.flowcrypt.email.Constants import com.flowcrypt.email.R import com.flowcrypt.email.TestConstants import com.flowcrypt.email.base.BaseTest +import com.flowcrypt.email.matchers.CustomMatchers.Companion.withRecyclerViewItemCount import com.flowcrypt.email.rules.AddAccountToDatabaseRule import com.flowcrypt.email.rules.ClearAppSettingsRule import com.flowcrypt.email.rules.FlowCryptMockWebServerRule @@ -35,7 +38,6 @@ import com.flowcrypt.email.util.TestGeneralUtil import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.RecordedRequest -import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.isEmptyString import org.hamcrest.Matchers.not import org.junit.After @@ -438,13 +440,18 @@ class ComposeScreenExternalIntentsFlowTest : BaseTest() { private fun checkRecipients(recipientsCount: Int) { if (recipientsCount > 0) { + onView(withId(R.id.recyclerViewChipsTo)) + .check(matches(isDisplayed())) + .check(matches(withRecyclerViewItemCount(recipientsCount + 1))) + for (i in 0 until recipientsCount) { - onView(withId(R.id.editTextRecipientTo)) - .check(matches(isDisplayed())).check(matches(withText(containsString(recipients[i])))) + onView(withId(R.id.recyclerViewChipsTo)) + .perform(scrollTo(withText(recipients[i]))) } } else { - onView(withId(R.id.editTextRecipientTo)) - .check(matches(isDisplayed())).check(matches(withText(isEmptyString()))) + onView(withId(R.id.recyclerViewChipsTo)) + .check(matches(isDisplayed())) + .check(matches(withRecyclerViewItemCount(1))) } } diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenFlowTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenFlowTest.kt index 944b1783a5..e2e2d1058c 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenFlowTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenFlowTest.kt @@ -9,16 +9,19 @@ import android.app.Activity import android.app.Instrumentation import android.content.ComponentName import android.content.Intent +import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onData import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu import androidx.test.espresso.action.ViewActions.clearText import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.pressImeActionButton import androidx.test.espresso.action.ViewActions.scrollTo import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent @@ -40,8 +43,10 @@ import com.flowcrypt.email.api.email.model.AttachmentInfo import com.flowcrypt.email.database.entity.PublicKeyEntity import com.flowcrypt.email.database.entity.RecipientEntity import com.flowcrypt.email.extensions.org.bouncycastle.openpgp.expiration +import com.flowcrypt.email.matchers.CustomMatchers.Companion.hasItem import com.flowcrypt.email.matchers.CustomMatchers.Companion.withAppBarLayoutBackgroundColor import com.flowcrypt.email.matchers.CustomMatchers.Companion.withChipsBackgroundColor +import com.flowcrypt.email.matchers.CustomMatchers.Companion.withRecyclerViewItemCount import com.flowcrypt.email.model.KeyImportDetails import com.flowcrypt.email.model.MessageEncryptionType import com.flowcrypt.email.rules.AddPrivateKeyToDatabaseRule @@ -52,8 +57,8 @@ import com.flowcrypt.email.rules.ScreenshotTestRule import com.flowcrypt.email.security.model.PgpKeyDetails import com.flowcrypt.email.security.pgp.PgpKey import com.flowcrypt.email.ui.activity.MainActivity +import com.flowcrypt.email.ui.adapter.RecipientChipRecyclerViewAdapter import com.flowcrypt.email.ui.base.BaseComposeScreenTest -import com.flowcrypt.email.ui.widget.CustomChipSpanChipCreator import com.flowcrypt.email.util.PrivateKeysManager import com.flowcrypt.email.util.TestGeneralUtil import com.flowcrypt.email.util.UIUtil @@ -65,7 +70,6 @@ import org.hamcrest.Description import org.hamcrest.Matcher import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.allOf -import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.emptyString import org.hamcrest.Matchers.not import org.junit.Assert @@ -114,16 +118,17 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { fun testEmptyRecipient() { activeActivityRule?.launch(intent) registerAllIdlingResources() - onView(withId(R.id.editTextRecipientTo)) - .check(matches(withText(`is`(emptyString())))) + + onView(withId(R.id.recyclerViewChipsTo)) + .check(matches(isDisplayed())) + .check(matches(withRecyclerViewItemCount(1))) + onView(withId(R.id.menuActionSend)) .check(matches(isDisplayed())) .perform(click()) - onView( - withText( - getResString(R.string.text_must_not_be_empty, getResString(R.string.prompt_recipients_to)) - ) - ) + .check(matches(isDisplayed())) + + onView(withText(getResString(R.string.add_recipient_to_send_message))) .check(matches(isDisplayed())) } @@ -132,10 +137,11 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { activeActivityRule?.launch(intent) registerAllIdlingResources() - onView(withId(R.id.layoutTo)) - .perform(scrollTo()) - onView(withId(R.id.editTextRecipientTo)) - .perform(typeText(TestConstants.RECIPIENT_WITH_PUBLIC_KEY_ON_ATTESTER)) + onView(withId(R.id.editTextEmailAddress)) + .perform( + typeText(TestConstants.RECIPIENT_WITH_PUBLIC_KEY_ON_ATTESTER), + pressImeActionButton() + ) onView(withId(R.id.editTextEmailSubject)) .perform(scrollTo(), click(), typeText("subject"), clearText()) .check(matches(withText(`is`(emptyString())))) @@ -143,14 +149,8 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { .check(matches(isDisplayed())) .perform(click()) onView( - withText( - getResString( - R.string.text_must_not_be_empty, - getResString(R.string.prompt_subject) - ) - ) - ) - .check(matches(isDisplayed())) + withText(getResString(R.string.text_must_not_be_empty, getResString(R.string.prompt_subject))) + ).check(matches(isDisplayed())) } @Test @@ -158,10 +158,11 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { activeActivityRule?.launch(intent) registerAllIdlingResources() - onView(withId(R.id.layoutTo)) - .perform(scrollTo()) - onView(withId(R.id.editTextRecipientTo)) - .perform(typeText(TestConstants.RECIPIENT_WITH_PUBLIC_KEY_ON_ATTESTER)) + onView(withId(R.id.editTextEmailAddress)) + .perform( + typeText(TestConstants.RECIPIENT_WITH_PUBLIC_KEY_ON_ATTESTER), + pressImeActionButton() + ) onView(withId(R.id.editTextEmailSubject)) .check(matches(isDisplayed())) .perform(scrollTo(), click(), typeText(EMAIL_SUBJECT)) @@ -238,10 +239,9 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { onView(withId(R.id.editTextFrom)) .perform(scrollTo()) .check(matches(withText(not(`is`(emptyString()))))) - onView(withId(R.id.layoutTo)) - .perform(scrollTo()) - onView(withId(R.id.editTextRecipientTo)) - .check(matches(withText(`is`(emptyString())))) + onView(withId(R.id.recyclerViewChipsTo)) + .check(matches(isDisplayed())) + .check(matches(withRecyclerViewItemCount(1))) onView(withId(R.id.editTextEmailSubject)) .perform(scrollTo()) .check(matches(withText(`is`(emptyString())))) @@ -255,18 +255,17 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { val invalidEmailAddresses = arrayOf("test", "test@", "test@@flowcrypt.test", "@flowcrypt.test") for (invalidEmailAddress in invalidEmailAddresses) { - onView(withId(R.id.layoutTo)) - .perform(scrollTo()) - onView(withId(R.id.editTextRecipientTo)) - .perform(clearText(), typeText(invalidEmailAddress), closeSoftKeyboard()) - onView(withId(R.id.menuActionSend)) - .check(matches(isDisplayed())) - .perform(click()) - onView(withText(getResString(R.string.error_some_email_is_not_valid, invalidEmailAddress))) - .check(matches(isDisplayed())) - onView(withId(com.google.android.material.R.id.snackbar_action)) + onView(withId(R.id.editTextEmailAddress)) + .perform( + clearText(), + typeText(invalidEmailAddress), + pressImeActionButton() + ) + + //after selecting typed text we check that new items were not added + onView(withId(R.id.recyclerViewChipsTo)) .check(matches(isDisplayed())) - .perform(click()) + .check(matches(withRecyclerViewItemCount(1))) } } @@ -275,10 +274,11 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { activeActivityRule?.launch(intent) registerAllIdlingResources() - onView(withId(R.id.layoutTo)) - .perform(scrollTo()) - onView(withId(R.id.editTextRecipientTo)) - .perform(closeSoftKeyboard()) + onView(withId(R.id.editTextEmailAddress)) + .perform( + clearText(), + pressImeActionButton() + ) for (att in atts) { addAttAndCheck(att) @@ -290,10 +290,11 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { activeActivityRule?.launch(intent) registerAllIdlingResources() - onView(withId(R.id.layoutTo)) - .perform(scrollTo()) - onView(withId(R.id.editTextRecipientTo)) - .perform(closeSoftKeyboard()) + onView(withId(R.id.editTextEmailAddress)) + .perform( + clearText(), + pressImeActionButton() + ) val fileWithBiggerSize = TestGeneralUtil.createFileWithGivenSize( Constants.MAX_TOTAL_ATTACHMENT_SIZE_IN_BYTES + 1024, temporaryFolderRule @@ -319,10 +320,11 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { activeActivityRule?.launch(intent) registerAllIdlingResources() - onView(withId(R.id.layoutTo)) - .perform(scrollTo()) - onView(withId(R.id.editTextRecipientTo)) - .perform(closeSoftKeyboard()) + onView(withId(R.id.editTextEmailAddress)) + .perform( + clearText(), + pressImeActionButton() + ) for (att in atts) { addAttAndCheck(att) @@ -349,15 +351,14 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { val email = requireNotNull(pgpKeyDetails.getPrimaryInternetAddress()?.address) fillInAllFields(email) - //check that we show the right background for a chip - onView(withId(R.id.editTextRecipientTo)) - .check( - matches( - withChipsBackgroundColor( - chipText = email, - backgroundColor = UIUtil.getColor( - context = getTargetContext(), - colorResourcesId = CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_NO_PUB_KEY + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + RecyclerViewActions.scrollTo( + allOf( + withText(email), + withChipsBackgroundColor( + getTargetContext(), + RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_NO_PUB_KEY ) ) ) @@ -375,19 +376,14 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { pgpKeyDetails.toPublicKeyEntity(email) ) - //move focus to request the field updates - onView(withId(R.id.editTextRecipientTo)) - .perform(scrollTo(), click()) - onView(withId(R.id.editTextEmailSubject)) - .perform(scrollTo(), click()) - onView(withId(R.id.editTextRecipientTo)) - .check( - matches( - withChipsBackgroundColor( - chipText = email, - backgroundColor = UIUtil.getColor( - context = getTargetContext(), - colorResourcesId = CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + RecyclerViewActions.scrollTo( + allOf( + withText(email), + withChipsBackgroundColor( + getTargetContext(), + RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY ) ) ) @@ -414,12 +410,10 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { activeActivityRule?.launch(intent) registerAllIdlingResources() - onView(withId(R.id.layoutTo)) - .perform(scrollTo()) - onView(withId(R.id.editTextRecipientTo)) + onView(withId(R.id.editTextEmailAddress)) .perform( typeText(TestConstants.RECIPIENT_WITHOUT_PUBLIC_KEY_ON_ATTESTER), - closeSoftKeyboard() + pressImeActionButton() ) //move the focus to the next view onView(withId(R.id.editTextEmailMessage)) @@ -440,19 +434,11 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { ) .check(matches(isDisplayed())) .perform(click()) - onView(withId(R.id.layoutTo)) - .perform(scrollTo()) - onView(withId(R.id.editTextRecipientTo)) - .check(matches(isDisplayed())) + + onView(withId(R.id.recyclerViewChipsTo)) .check( matches( - withText( - not( - containsString( - TestConstants.RECIPIENT_WITHOUT_PUBLIC_KEY_ON_ATTESTER - ) - ) - ) + not(hasItem(withText(TestConstants.RECIPIENT_WITHOUT_PUBLIC_KEY_ON_ATTESTER))) ) ) } @@ -488,20 +474,17 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { .check(matches(isDisplayed())) .perform(click()) - onView(withId(R.id.editTextRecipientTo)) - .perform(scrollTo(), click(), closeSoftKeyboard()) - onView(withId(R.id.editTextEmailSubject)) .perform(scrollTo(), click()) - onView(withId(R.id.editTextRecipientTo)) - .check( - matches( - withChipsBackgroundColor( - chipText = TestConstants.RECIPIENT_WITHOUT_PUBLIC_KEY_ON_ATTESTER, - backgroundColor = UIUtil.getColor( - context = getTargetContext(), - colorResourcesId = CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + RecyclerViewActions.scrollTo( + allOf( + withText(TestConstants.RECIPIENT_WITHOUT_PUBLIC_KEY_ON_ATTESTER), + withChipsBackgroundColor( + getTargetContext(), + RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY ) ) ) @@ -592,6 +575,7 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { val keyDetails = PrivateKeysManager.getPgpKeyDetailsFromAssets("pgp/expired@flowcrypt.test_pub.asc") val email = requireNotNull(keyDetails.getPrimaryInternetAddress()).address + val personal = requireNotNull(keyDetails.getPrimaryInternetAddress()).personal roomDatabase.recipientDao().insert(requireNotNull(keyDetails.toRecipientEntity())) roomDatabase.pubKeyDao().insert(keyDetails.toPublicKeyEntity(email)) @@ -600,14 +584,14 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { fillInAllFields(email) - onView(withId(R.id.editTextRecipientTo)) - .check( - matches( - withChipsBackgroundColor( - email, - UIUtil.getColor( + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + RecyclerViewActions.scrollTo( + allOf( + withText(personal), + withChipsBackgroundColor( getTargetContext(), - CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_HAS_PUB_KEY_BUT_EXPIRED + RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_HAS_PUB_KEY_BUT_EXPIRED ) ) ) @@ -641,20 +625,19 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { fillInAllFields(email) - onView(withId(R.id.editTextRecipientTo)) - .check( - matches( - withChipsBackgroundColor( - email, - UIUtil.getColor( + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + RecyclerViewActions.scrollTo( + allOf( + withText(email), + withChipsBackgroundColor( getTargetContext(), - CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY + RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY ) ) ) ) - /*temporary disabled due too https://github.com/FlowCrypt/flowcrypt-android/issues/1478 onView(withId(R.id.menuActionSend)) .check(matches(isDisplayed())) @@ -691,12 +674,15 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { fillInAllFields(internetAddress.address) - onView(withId(R.id.editTextRecipientTo)) - .check( - matches( - withChipsBackgroundColor( - internetAddress.address, - UIUtil.getColor(getTargetContext(), R.color.colorPrimary) + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + RecyclerViewActions.scrollTo( + allOf( + withText(internetAddress.personal), + withChipsBackgroundColor( + getTargetContext(), + R.color.colorPrimary + ) ) ) ) @@ -707,10 +693,10 @@ class ComposeScreenFlowTest : BaseComposeScreenTest() { activeActivityRule?.launch(intent) registerAllIdlingResources() - onView(withId(R.id.editTextRecipientTo)) + onView(withId(R.id.editTextEmailAddress)) .perform( typeText(TestConstants.RECIPIENT_WITHOUT_PUBLIC_KEY_ON_ATTESTER), - closeSoftKeyboard() + pressImeActionButton() ) //need to leave focus from 'To' field. move the focus to the next view diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenImportRecipientPubKeyFlowTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenImportRecipientPubKeyFlowTest.kt index a656e015a1..2e8bef430d 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenImportRecipientPubKeyFlowTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenImportRecipientPubKeyFlowTest.kt @@ -5,10 +5,12 @@ package com.flowcrypt.email.ui +import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.scrollTo import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText @@ -17,17 +19,17 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.flowcrypt.email.R import com.flowcrypt.email.TestConstants -import com.flowcrypt.email.matchers.CustomMatchers +import com.flowcrypt.email.matchers.CustomMatchers.Companion.withChipsBackgroundColor import com.flowcrypt.email.rules.AddPrivateKeyToDatabaseRule import com.flowcrypt.email.rules.ClearAppSettingsRule import com.flowcrypt.email.rules.LazyActivityScenarioRule import com.flowcrypt.email.rules.RetryRule import com.flowcrypt.email.rules.ScreenshotTestRule import com.flowcrypt.email.ui.activity.CreateMessageActivity +import com.flowcrypt.email.ui.adapter.RecipientChipRecyclerViewAdapter import com.flowcrypt.email.ui.base.BaseComposeScreenTest -import com.flowcrypt.email.ui.widget.CustomChipSpanChipCreator import com.flowcrypt.email.util.TestGeneralUtil -import com.flowcrypt.email.util.UIUtil +import org.hamcrest.Matchers.allOf import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain @@ -115,14 +117,14 @@ class ComposeScreenImportRecipientPubKeyFlowTest : BaseComposeScreenTest() { } private fun checkThatRecipientHasPublicKey() { - onView(withId(R.id.editTextRecipientTo)) - .check( - matches( - CustomMatchers.withChipsBackgroundColor( - chipText = TestConstants.RECIPIENT_WITHOUT_PUBLIC_KEY_ON_ATTESTER, - backgroundColor = UIUtil.getColor( - context = getTargetContext(), - colorResourcesId = CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + scrollTo( + allOf( + withText(TestConstants.RECIPIENT_WITHOUT_PUBLIC_KEY_ON_ATTESTER), + withChipsBackgroundColor( + getTargetContext(), + RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY ) ) ) diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenPasswordProtectedFlowTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenPasswordProtectedFlowTest.kt index 0ef12ce9de..c19173882c 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenPasswordProtectedFlowTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenPasswordProtectedFlowTest.kt @@ -5,14 +5,16 @@ package com.flowcrypt.email.ui +import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu -import androidx.test.espresso.action.ViewActions.clearText import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.pressImeActionButton import androidx.test.espresso.action.ViewActions.scrollTo import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText @@ -29,6 +31,7 @@ import com.flowcrypt.email.rules.RetryRule import com.flowcrypt.email.rules.ScreenshotTestRule import com.flowcrypt.email.ui.base.BaseComposeScreenTest import com.flowcrypt.email.util.AccountDaoManager +import com.flowcrypt.email.viewaction.CustomViewActions.clickOnChipCloseIcon import org.hamcrest.Matchers.not import org.junit.Rule import org.junit.Test @@ -67,9 +70,10 @@ class ComposeScreenPasswordProtectedFlowTest : BaseComposeScreenTest() { activeActivityRule?.launch(intent) registerAllIdlingResources() - onView(withId(R.id.editTextRecipientTo)) + onView(withId(R.id.editTextEmailAddress)) .perform( typeText(TestConstants.RECIPIENT_WITHOUT_PUBLIC_KEY_ON_ATTESTER), + pressImeActionButton(), closeSoftKeyboard() ) @@ -95,18 +99,22 @@ class ComposeScreenPasswordProtectedFlowTest : BaseComposeScreenTest() { activeActivityRule?.launch(intent) registerAllIdlingResources() - onView(withId(R.id.editTextRecipientTo)) + onView(withId(R.id.editTextEmailAddress)) .perform( typeText(TestConstants.RECIPIENT_WITHOUT_PUBLIC_KEY_ON_ATTESTER), - closeSoftKeyboard() + pressImeActionButton() ) + //need to leave focus from 'To' field. move the focus to the next view onView(withId(R.id.editTextEmailSubject)) .perform(scrollTo(), click()) - onView(withId(R.id.editTextRecipientTo)) - .perform( - clearText(), typeText("some text"), clearText(), - ) + + onView(withId(R.id.btnSetWebPortalPassword)) + .check(matches(isDisplayed())) + + onView(withId(R.id.recyclerViewChipsTo)).perform( + actionOnItemAtPosition(0, clickOnChipCloseIcon()) + ) //need to leave focus from 'To' field. move the focus to the next view onView(withId(R.id.editTextEmailSubject)) .perform(scrollTo(), click()) diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenReplyAllFlowTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenReplyAllFlowTest.kt index 42e38afad5..0b27d50c66 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenReplyAllFlowTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenReplyAllFlowTest.kt @@ -89,7 +89,7 @@ class ComposeScreenReplyAllFlowTest : BaseTest() { registerAllIdlingResources() - onView(withId(R.id.editTextRecipientCc)) + onView(withId(R.id.chipLayoutCc)) .check(matches(not(isDisplayed()))) } } diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenReplyFlowTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenReplyFlowTest.kt index 60091c26ac..6897bb88c3 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenReplyFlowTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenReplyFlowTest.kt @@ -6,9 +6,9 @@ package com.flowcrypt.email.ui import android.content.Intent +import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.contrib.RecyclerViewActions.scrollTo import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.activityScenarioRule @@ -24,7 +24,6 @@ import com.flowcrypt.email.rules.RetryRule import com.flowcrypt.email.rules.ScreenshotTestRule import com.flowcrypt.email.ui.activity.CreateMessageActivity import com.flowcrypt.email.ui.activity.fragment.CreateMessageFragmentArgs -import com.hootsuite.nachos.tokenizer.SpanChipTokenizer import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain @@ -70,18 +69,12 @@ class ComposeScreenReplyFlowTest : BaseTest() { @Test fun testReplyToHeader() { - onView(withId(R.id.editTextRecipientTo)) - .check(matches(isDisplayed())) - .check(matches(withText(prepareChipText(msgInfo?.getReplyTo()?.first()?.address)))) - } - - private fun prepareChipText(text: String?): String { - val chipSeparator = SpanChipTokenizer.CHIP_SPAN_SEPARATOR.toString() - val autoCorrectSeparator = SpanChipTokenizer.AUTOCORRECT_SEPARATOR.toString() - return (autoCorrectSeparator - + chipSeparator - + text - + chipSeparator - + autoCorrectSeparator) + Thread.sleep(1000) + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + scrollTo( + withText(msgInfo?.getReplyTo()?.first()?.address) + ) + ) } } diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenWkdFlowTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenWkdFlowTest.kt index 65dd0751f1..3733fd8d68 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenWkdFlowTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/ComposeScreenWkdFlowTest.kt @@ -5,10 +5,12 @@ package com.flowcrypt.email.ui +import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.scrollTo import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.activityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest @@ -23,14 +25,14 @@ import com.flowcrypt.email.rules.LazyActivityScenarioRule import com.flowcrypt.email.rules.RetryRule import com.flowcrypt.email.rules.ScreenshotTestRule import com.flowcrypt.email.ui.activity.CreateMessageActivity +import com.flowcrypt.email.ui.adapter.RecipientChipRecyclerViewAdapter import com.flowcrypt.email.ui.base.BaseComposeScreenTest -import com.flowcrypt.email.ui.widget.CustomChipSpanChipCreator import com.flowcrypt.email.util.TestGeneralUtil -import com.flowcrypt.email.util.UIUtil import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.RecordedRequest import okio.Buffer +import org.hamcrest.Matchers.allOf import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain @@ -102,7 +104,7 @@ class ComposeScreenWkdFlowTest : BaseComposeScreenTest() { fun testWkdAdvancedNoResult() { check( recipient = "wkd_advanced_no_result@localhost", - colorResourcesId = CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_NO_PUB_KEY + colorResourcesId = RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_NO_PUB_KEY ) } @@ -110,7 +112,7 @@ class ComposeScreenWkdFlowTest : BaseComposeScreenTest() { fun testWkdAdvancedPub() { check( recipient = "wkd_advanced_pub@localhost", - colorResourcesId = CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY + colorResourcesId = RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY ) } @@ -118,7 +120,7 @@ class ComposeScreenWkdFlowTest : BaseComposeScreenTest() { fun testWkdAdvancedSkippedWkdDirectNoPolicyPub() { check( recipient = "wkd_direct_no_policy@localhost", - colorResourcesId = CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_NO_PUB_KEY + colorResourcesId = RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_NO_PUB_KEY ) } @@ -126,7 +128,7 @@ class ComposeScreenWkdFlowTest : BaseComposeScreenTest() { fun testWkdAdvancedSkippedWkdDirectNoResult() { check( recipient = "wkd_direct_no_result@localhost", - colorResourcesId = CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_NO_PUB_KEY + colorResourcesId = RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_NO_PUB_KEY ) } @@ -134,7 +136,7 @@ class ComposeScreenWkdFlowTest : BaseComposeScreenTest() { fun testWkdAdvancedSkippedWkdDirectPub() { check( recipient = "wkd_direct_pub@localhost", - colorResourcesId = CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY + colorResourcesId = RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY ) } @@ -142,7 +144,7 @@ class ComposeScreenWkdFlowTest : BaseComposeScreenTest() { fun testWkdAdvancedTimeOutWkdDirectAvailable() { check( recipient = "wkd_direct_pub@localhost", - colorResourcesId = CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY + colorResourcesId = RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY ) } @@ -150,7 +152,7 @@ class ComposeScreenWkdFlowTest : BaseComposeScreenTest() { fun testWkdAdvancedTimeOutWkdDirectTimeOut() { check( recipient = "wkd_advanced_direct_timeout@localhost", - colorResourcesId = CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_NO_PUB_KEY + colorResourcesId = RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_NO_PUB_KEY ) } @@ -158,7 +160,7 @@ class ComposeScreenWkdFlowTest : BaseComposeScreenTest() { fun testWkdPrv() { check( recipient = "wkd_prv@localhost", - colorResourcesId = CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_NO_PUB_KEY + colorResourcesId = RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_NO_PUB_KEY ) } @@ -236,16 +238,10 @@ class ComposeScreenWkdFlowTest : BaseComposeScreenTest() { private fun check(recipient: String, colorResourcesId: Int) { fillInAllFields(recipient) - onView(withId(R.id.editTextRecipientTo)) - .check( - matches( - withChipsBackgroundColor( - chipText = recipient, - backgroundColor = UIUtil.getColor( - context = getTargetContext(), - colorResourcesId = colorResourcesId - ) - ) + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + scrollTo( + allOf(withText(recipient), withChipsBackgroundColor(getTargetContext(), colorResourcesId)) ) ) } diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/activity/SelectRecipientsActivityTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/activity/SelectRecipientsActivityTest.kt index e58d35bfd7..609d5dd0c7 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/activity/SelectRecipientsActivityTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/activity/SelectRecipientsActivityTest.kt @@ -34,7 +34,7 @@ import com.flowcrypt.email.rules.AddRecipientsToDatabaseRule import com.flowcrypt.email.rules.ClearAppSettingsRule import com.flowcrypt.email.rules.RetryRule import com.flowcrypt.email.rules.ScreenshotTestRule -import com.flowcrypt.email.viewaction.CustomActions.Companion.doNothing +import com.flowcrypt.email.viewaction.CustomViewActions.doNothing import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.not import org.junit.After @@ -117,10 +117,10 @@ class SelectRecipientsActivityTest : BaseTest() { ) ) } else { - onView(withId(R.id.recyclerViewContacts)).perform( + /*onView(withId(R.id.recyclerViewContacts)).perform( actionOnItem (hasDescendant(allOf(withId(R.id.textViewOnlyEmail), withText(EMAILS[i]))), doNothing()) - ) + )*/ } } } @@ -136,7 +136,7 @@ class SelectRecipientsActivityTest : BaseTest() { if (i % 2 == 0) { checkIsTypedUserFound(R.id.textViewName, getUserName(EMAILS[i])) } else { - checkIsTypedUserFound(R.id.textViewOnlyEmail, EMAILS[i]) + /*checkIsTypedUserFound(R.id.textViewOnlyEmail, EMAILS[i])*/ } } } diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/activity/StandardReplyWithServiceInfoAndOneFileTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/activity/StandardReplyWithServiceInfoAndOneFileTest.kt index eedc462bed..5c232a6552 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/activity/StandardReplyWithServiceInfoAndOneFileTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/activity/StandardReplyWithServiceInfoAndOneFileTest.kt @@ -6,11 +6,13 @@ package com.flowcrypt.email.ui.activity import android.text.TextUtils +import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.replaceText import androidx.test.espresso.action.ViewActions.scrollTo import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isFocusable import androidx.test.espresso.matcher.ViewMatchers.withId @@ -26,6 +28,8 @@ import com.flowcrypt.email.api.email.model.IncomingMessageInfo import com.flowcrypt.email.api.email.model.ServiceInfo import com.flowcrypt.email.base.BaseTest import com.flowcrypt.email.junit.annotations.NotReadyForCI +import com.flowcrypt.email.matchers.CustomMatchers +import com.flowcrypt.email.matchers.CustomMatchers.Companion.withChipCloseIconAvailability import com.flowcrypt.email.model.MessageEncryptionType import com.flowcrypt.email.model.MessageType import com.flowcrypt.email.rules.AddAccountToDatabaseRule @@ -34,7 +38,6 @@ import com.flowcrypt.email.rules.RetryRule import com.flowcrypt.email.rules.ScreenshotTestRule import com.flowcrypt.email.util.AccountDaoManager import com.flowcrypt.email.util.TestGeneralUtil -import com.hootsuite.nachos.tokenizer.SpanChipTokenizer import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.isEmptyString import org.hamcrest.Matchers.not @@ -85,7 +88,7 @@ class StandardReplyWithServiceInfoAndOneFileTest : BaseTest() { ) override val useIntents: Boolean = true - override val activityScenarioRule = activityScenarioRule( + override val activityScenarioRule = activityScenarioRule( intent = CreateMessageActivity.generateIntent( getTargetContext(), msgInfo = incomingMsgInfo, @@ -119,24 +122,20 @@ class StandardReplyWithServiceInfoAndOneFileTest : BaseTest() { @Test fun testToRecipients() { - val chipSeparator = SpanChipTokenizer.CHIP_SPAN_SEPARATOR.toString() - val autoCorrectSeparator = SpanChipTokenizer.AUTOCORRECT_SEPARATOR.toString() - val textWithSeparator = (autoCorrectSeparator - + chipSeparator - + incomingMsgInfo.getFrom().first().address - + chipSeparator - + autoCorrectSeparator) - - onView(withId(R.id.editTextRecipientTo)) - .perform(scrollTo()) - .check( - matches( + Thread.sleep(1000) + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + RecyclerViewActions.scrollTo( allOf( - isDisplayed(), withText(textWithSeparator), - if (serviceInfo.isToFieldEditable) isFocusable() else not(isFocusable()) + withText(incomingMsgInfo.getFrom().first().address), + withChipCloseIconAvailability(false) ) ) ) + + onView(withId(R.id.recyclerViewChipsTo)) + .check(matches(isDisplayed())) + .check(matches(CustomMatchers.withRecyclerViewItemCount(incomingMsgInfo.getFrom().size))) } @Test diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/base/BaseComposeScreenTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/base/BaseComposeScreenTest.kt index ce6a31ab24..81d568e0fd 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/base/BaseComposeScreenTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/base/BaseComposeScreenTest.kt @@ -10,6 +10,7 @@ import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.pressImeActionButton import androidx.test.espresso.action.ViewActions.scrollTo import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.matcher.ViewMatchers.withId @@ -48,10 +49,10 @@ abstract class BaseComposeScreenTest : BaseTest() { } protected fun fillInAllFields(recipient: String) { - onView(withId(R.id.layoutTo)) + onView(withId(R.id.chipLayoutTo)) .perform(scrollTo()) - onView(withId(R.id.editTextRecipientTo)) - .perform(typeText(recipient), closeSoftKeyboard()) + onView(withId(R.id.editTextEmailAddress)) + .perform(typeText(recipient), pressImeActionButton(), closeSoftKeyboard()) //need to leave focus from 'To' field. move the focus to the next view onView(withId(R.id.editTextEmailSubject)) .perform( diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/CreateMessageFragmentDisallowAttesterSearchForDomainInIsolationTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/CreateMessageFragmentDisallowAttesterSearchForDomainInIsolationTest.kt index 0807731066..de56838063 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/CreateMessageFragmentDisallowAttesterSearchForDomainInIsolationTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/CreateMessageFragmentDisallowAttesterSearchForDomainInIsolationTest.kt @@ -5,12 +5,15 @@ package com.flowcrypt.email.ui.fragment.isolation.incontainer +import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.pressImeActionButton import androidx.test.espresso.action.ViewActions.typeText -import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.scrollTo import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.flowcrypt.email.R @@ -27,10 +30,9 @@ import com.flowcrypt.email.rules.RetryRule import com.flowcrypt.email.rules.ScreenshotTestRule import com.flowcrypt.email.ui.activity.fragment.CreateMessageFragment import com.flowcrypt.email.ui.activity.fragment.CreateMessageFragmentArgs -import com.flowcrypt.email.ui.widget.CustomChipSpanChipCreator +import com.flowcrypt.email.ui.adapter.RecipientChipRecyclerViewAdapter import com.flowcrypt.email.util.AccountDaoManager -import com.flowcrypt.email.util.UIUtil -import org.junit.Ignore +import org.hamcrest.Matchers.allOf import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain @@ -79,16 +81,19 @@ class CreateMessageFragmentDisallowAttesterSearchForDomainInIsolationTest : Base .around(ScreenshotTestRule()) @Test - @Ignore("fix me. Fails sometimes") fun testCanLookupThisRecipientOnAttester() { launchFragmentInContainer( fragmentArgs = CreateMessageFragmentArgs().toBundle() ) val recipient = "user@$DISALLOWED_DOMAIN" + onView(withId(R.id.editTextEmailAddress)) + .perform( + typeText(recipient), + pressImeActionButton(), + closeSoftKeyboard() + ) - onView(withId(R.id.editTextRecipientTo)) - .perform(typeText(recipient), closeSoftKeyboard()) //need to leave focus from 'To' field. move the focus to the next view onView(withId(R.id.editTextEmailSubject)) .perform( @@ -102,14 +107,14 @@ class CreateMessageFragmentDisallowAttesterSearchForDomainInIsolationTest : Base closeSoftKeyboard() ) - onView(withId(R.id.editTextRecipientTo)) - .check( - matches( - withChipsBackgroundColor( - recipient, - UIUtil.getColor( + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + scrollTo( + allOf( + withText(recipient), + withChipsBackgroundColor( getTargetContext(), - CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_NO_PUB_KEY + RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_NO_PUB_KEY ) ) ) diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/CreateMessageFragmentDisallowAttesterSearchInIsolationTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/CreateMessageFragmentDisallowAttesterSearchInIsolationTest.kt index 1a5a914966..60ea412f25 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/CreateMessageFragmentDisallowAttesterSearchInIsolationTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/CreateMessageFragmentDisallowAttesterSearchInIsolationTest.kt @@ -5,12 +5,15 @@ package com.flowcrypt.email.ui.fragment.isolation.incontainer +import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.pressImeActionButton import androidx.test.espresso.action.ViewActions.typeText -import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.scrollTo import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.flowcrypt.email.R @@ -27,10 +30,9 @@ import com.flowcrypt.email.rules.RetryRule import com.flowcrypt.email.rules.ScreenshotTestRule import com.flowcrypt.email.ui.activity.fragment.CreateMessageFragment import com.flowcrypt.email.ui.activity.fragment.CreateMessageFragmentArgs -import com.flowcrypt.email.ui.widget.CustomChipSpanChipCreator +import com.flowcrypt.email.ui.adapter.RecipientChipRecyclerViewAdapter import com.flowcrypt.email.util.AccountDaoManager -import com.flowcrypt.email.util.UIUtil -import org.junit.Ignore +import org.hamcrest.Matchers.allOf import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain @@ -78,16 +80,19 @@ class CreateMessageFragmentDisallowAttesterSearchInIsolationTest : BaseTest() { .around(ScreenshotTestRule()) @Test - @Ignore("fix me. Fails sometimes") fun testDisallowLookupOnAttester() { launchFragmentInContainer( fragmentArgs = CreateMessageFragmentArgs().toBundle() ) val recipient = "recipient@example.test" + onView(withId(R.id.editTextEmailAddress)) + .perform( + typeText(recipient), + pressImeActionButton(), + closeSoftKeyboard() + ) - onView(withId(R.id.editTextRecipientTo)) - .perform(typeText(recipient), closeSoftKeyboard()) //need to leave focus from 'To' field. move the focus to the next view onView(withId(R.id.editTextEmailSubject)) .perform( @@ -101,14 +106,14 @@ class CreateMessageFragmentDisallowAttesterSearchInIsolationTest : BaseTest() { closeSoftKeyboard() ) - onView(withId(R.id.editTextRecipientTo)) - .check( - matches( - withChipsBackgroundColor( - recipient, - UIUtil.getColor( + onView(withId(R.id.recyclerViewChipsTo)) + .perform( + scrollTo( + allOf( + withText(recipient), + withChipsBackgroundColor( getTargetContext(), - CustomChipSpanChipCreator.CHIP_COLOR_RES_ID_NO_PUB_KEY + RecipientChipRecyclerViewAdapter.CHIP_COLOR_RES_ID_NO_PUB_KEY ) ) ) diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/ChipCloseIconClickViewAction.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/ChipCloseIconClickViewAction.kt new file mode 100644 index 0000000000..86fafb4211 --- /dev/null +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/ChipCloseIconClickViewAction.kt @@ -0,0 +1,19 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: denbond7 + */ + +package com.flowcrypt.email.viewaction + +import android.view.View +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import com.google.android.material.chip.Chip + +class ChipCloseIconClickViewAction : ViewAction { + override fun getConstraints() = null + override fun getDescription() = "Click on a close icon" + override fun perform(uiController: UiController, view: View) { + (view as? Chip)?.performCloseIconClick() + } +} diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/CustomActions.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/CustomActions.kt deleted file mode 100644 index 34711deaef..0000000000 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/CustomActions.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 - */ - -package com.flowcrypt.email.viewaction - -import androidx.test.espresso.ViewAction - -/** - * @author Denis Bondarenko - * Date: 5/21/20 - * Time: 3:00 PM - * E-mail: DenBond7@gmail.com - */ -class CustomActions { - companion object { - fun doNothing(): ViewAction { - return EmptyAction() - } - } -} diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/CustomViewActions.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/CustomViewActions.kt index 2f9ca5a1c7..b3359708ea 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/CustomViewActions.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/CustomViewActions.kt @@ -41,11 +41,12 @@ import com.google.android.material.navigation.NavigationView * @return a [ViewAction] that navigates on a menu item */ -class CustomViewActions { - companion object { - @JvmStatic - fun navigateToItemWithName(menuItemName: String): ViewAction { - return NavigateToItemViewAction(menuItemName) - } +object CustomViewActions { + fun doNothing(): ViewAction { + return EmptyAction() + } + + fun clickOnChipCloseIcon(): ViewAction { + return ChipCloseIconClickViewAction() } } diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/NavigateToItemViewAction.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/NavigateToItemViewAction.kt deleted file mode 100644 index d0ce63430f..0000000000 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/viewaction/NavigateToItemViewAction.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 - */ - -package com.flowcrypt.email.viewaction - -import android.content.res.Resources -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.test.espresso.PerformException -import androidx.test.espresso.UiController -import androidx.test.espresso.ViewAction -import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom -import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast -import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility -import androidx.test.espresso.util.HumanReadables -import com.google.android.material.internal.NavigationMenu -import com.google.android.material.navigation.NavigationView -import org.hamcrest.Matcher -import org.hamcrest.Matchers.allOf - -/** - * Create a [ViewAction] that navigates to a menu item in [NavigationView] using a menu item title. - * - * @author Denis Bondarenko - * Date: 11/28/18 - * Time: 11:16 AM - * E-mail: DenBond7@gmail.com - */ -class NavigateToItemViewAction(private val menuItemName: String) : ViewAction { - - override fun perform(uiController: UiController, view: View) { - val navigationView = view as NavigationView - val navigationMenu = navigationView.menu as NavigationMenu - - var matchedMenuItem: MenuItem? = null - - for (i in 0 until navigationMenu.size()) { - val menuItem = navigationMenu.getItem(i) - if (menuItem.hasSubMenu()) { - val subMenu = menuItem.subMenu - for (j in 0 until subMenu.size()) { - val subMenuItem = subMenu.getItem(j) - if (subMenuItem.title == menuItemName) { - matchedMenuItem = subMenuItem - } - } - } else { - if (menuItem.title == menuItemName) { - matchedMenuItem = menuItem - } - } - } - - if (matchedMenuItem == null) { - throw PerformException.Builder() - .withActionDescription(this.description) - .withViewDescription(HumanReadables.describe(view)) - .withCause(RuntimeException(getErrorMsg(navigationMenu, view))) - .build() - } - navigationMenu.performItemAction(matchedMenuItem, 0) - } - - override fun getDescription(): String { - return "click on menu item with id" - } - - override fun getConstraints(): Matcher { - return allOf( - isAssignableFrom(NavigationView::class.java), - withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), - isDisplayingAtLeast(90) - ) - } - - private fun getErrorMsg(menu: Menu, view: View): String { - val newLine = System.getProperty("line.separator") - val errorMsg = StringBuilder("Menu item was not found, available menu items:").append(newLine) - for (position in 0 until menu.size()) { - errorMsg.append("[MenuItem] position=").append(position) - val menuItem = menu.getItem(position) - if (menuItem != null) { - val itemTitle = menuItem.title - if (itemTitle != null) { - errorMsg.append(", title=").append(itemTitle) - } - if (view.resources != null) { - val itemId = menuItem.itemId - try { - errorMsg.append(", id=") - val menuItemResourceName = view.resources.getResourceName(itemId) - errorMsg.append(menuItemResourceName) - } catch (nfe: Resources.NotFoundException) { - errorMsg.append("not found") - } - - } - errorMsg.append(newLine) - } - } - return errorMsg.toString() - } -} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/model/ServiceInfo.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/model/ServiceInfo.kt index 3293ed7fb6..da76cea61f 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/model/ServiceInfo.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/model/ServiceInfo.kt @@ -18,6 +18,8 @@ import android.os.Parcelable */ data class ServiceInfo constructor( val isToFieldEditable: Boolean = false, + val isCcFieldEditable: Boolean = false, + val isBccFieldEditable: Boolean = false, val isFromFieldEditable: Boolean = false, val isMsgEditable: Boolean = false, val isSubjectEditable: Boolean = false, @@ -33,12 +35,16 @@ data class ServiceInfo constructor( parcel.readByte() != 0.toByte(), parcel.readByte() != 0.toByte(), parcel.readByte() != 0.toByte(), + parcel.readByte() != 0.toByte(), + parcel.readByte() != 0.toByte(), parcel.readString(), parcel.createTypedArrayList(AttachmentInfo.CREATOR) ) override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeByte(if (isToFieldEditable) 1 else 0) + parcel.writeByte(if (isCcFieldEditable) 1 else 0) + parcel.writeByte(if (isBccFieldEditable) 1 else 0) parcel.writeByte(if (isFromFieldEditable) 1 else 0) parcel.writeByte(if (isMsgEditable) 1 else 0) parcel.writeByte(if (isSubjectEditable) 1 else 0) diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/RecipientDao.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/RecipientDao.kt index 11fe170d35..1e1aa6704f 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/RecipientDao.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/database/dao/RecipientDao.kt @@ -77,6 +77,9 @@ interface RecipientDao : BaseDao { @Query("SELECT * FROM recipients WHERE email LIKE :searchPattern ORDER BY last_use DESC") fun getFilteredCursor(searchPattern: String): Cursor? + @Query("SELECT * FROM recipients WHERE email LIKE :searchPattern ORDER BY last_use DESC") + suspend fun findMatchingRecipients(searchPattern: String): List + @Query("DELETE FROM recipients") suspend fun deleteAll(): Int diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/ComposeMsgViewModel.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/ComposeMsgViewModel.kt index 24bf4e8cad..136e8e00e6 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/ComposeMsgViewModel.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/ComposeMsgViewModel.kt @@ -6,14 +6,36 @@ package com.flowcrypt.email.jetpack.viewmodel import android.app.Application +import androidx.lifecycle.viewModelScope +import com.flowcrypt.email.api.retrofit.ApiRepository +import com.flowcrypt.email.api.retrofit.FlowcryptApiRepository +import com.flowcrypt.email.api.retrofit.response.attester.PubResponse +import com.flowcrypt.email.api.retrofit.response.base.ApiError +import com.flowcrypt.email.api.retrofit.response.base.Result +import com.flowcrypt.email.database.FlowCryptRoomDatabase +import com.flowcrypt.email.database.entity.AccountEntity +import com.flowcrypt.email.database.entity.RecipientEntity import com.flowcrypt.email.database.entity.relation.RecipientWithPubKeys +import com.flowcrypt.email.extensions.kotlin.isValidEmail import com.flowcrypt.email.model.MessageEncryptionType +import com.flowcrypt.email.security.model.PgpKeyDetails +import com.flowcrypt.email.security.pgp.PgpKey +import com.flowcrypt.email.ui.adapter.RecipientChipRecyclerViewAdapter.RecipientInfo +import com.flowcrypt.email.util.exception.ApiException import jakarta.mail.Message +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException import java.io.InvalidObjectException +import java.util.concurrent.ConcurrentHashMap /** * @author Denis Bondarenko @@ -22,7 +44,14 @@ import java.io.InvalidObjectException * E-mail: DenBond7@gmail.com */ class ComposeMsgViewModel(isCandidateToEncrypt: Boolean, application: Application) : - BaseAndroidViewModel(application) { + RoomBasicViewModel(application) { + private val recipientLookUpManager = + RecipientLookUpManager(application, roomDatabase, viewModelScope) { + replaceRecipient(Message.RecipientType.TO, it) + replaceRecipient(Message.RecipientType.CC, it) + replaceRecipient(Message.RecipientType.BCC, it) + } + private val messageEncryptionTypeMutableStateFlow: MutableStateFlow = MutableStateFlow( if (isCandidateToEncrypt) { @@ -39,22 +68,19 @@ class ComposeMsgViewModel(isCandidateToEncrypt: Boolean, application: Applicatio val webPortalPasswordStateFlow: StateFlow = webPortalPasswordMutableStateFlow.asStateFlow() - //session cache for recipients - private val recipientsTo = mutableMapOf() - private val recipientsCc = mutableMapOf() - private val recipientsBcc = mutableMapOf() - - private val recipientsToMutableStateFlow: MutableStateFlow> = - MutableStateFlow(emptyList()) - val recipientsToStateFlow: StateFlow> = + private val recipientsToMutableStateFlow: MutableStateFlow> = + MutableStateFlow(mutableMapOf()) + val recipientsToStateFlow: StateFlow> = recipientsToMutableStateFlow.asStateFlow() - private val recipientsCcMutableStateFlow: MutableStateFlow> = - MutableStateFlow(emptyList()) - val recipientsCcStateFlow: StateFlow> = + + private val recipientsCcMutableStateFlow: MutableStateFlow> = + MutableStateFlow(mutableMapOf()) + val recipientsCcStateFlow: StateFlow> = recipientsCcMutableStateFlow.asStateFlow() - private val recipientsBccMutableStateFlow: MutableStateFlow> = - MutableStateFlow(emptyList()) - val recipientsBccStateFlow: StateFlow> = + + private val recipientsBccMutableStateFlow: MutableStateFlow> = + MutableStateFlow(mutableMapOf()) + val recipientsBccStateFlow: StateFlow> = recipientsBccMutableStateFlow.asStateFlow() val recipientsStateFlow = combine( @@ -67,14 +93,14 @@ class ComposeMsgViewModel(isCandidateToEncrypt: Boolean, application: Applicatio val msgEncryptionType: MessageEncryptionType get() = messageEncryptionTypeStateFlow.value - val recipientWithPubKeysTo: List - get() = recipientsTo.values.toList() - val recipientWithPubKeysCc: List - get() = recipientsCc.values.toList() - val recipientWithPubKeysBcc: List - get() = recipientsBcc.values.toList() - val recipientWithPubKeys: List - get() = recipientWithPubKeysTo + recipientWithPubKeysCc + recipientWithPubKeysBcc + val recipientsTo: Map + get() = recipientsToStateFlow.value + val recipientsCc: Map + get() = recipientsCcStateFlow.value + val recipientsBcc: Map + get() = recipientsBccStateFlow.value + val allRecipients: Map + get() = recipientsTo + recipientsCc + recipientsBcc fun switchMessageEncryptionType(messageEncryptionType: MessageEncryptionType) { messageEncryptionTypeMutableStateFlow.value = messageEncryptionType @@ -84,60 +110,308 @@ class ComposeMsgViewModel(isCandidateToEncrypt: Boolean, application: Applicatio webPortalPasswordMutableStateFlow.value = webPortalPassword } - fun replaceRecipients(recipientType: Message.RecipientType, list: List) { - val existingRecipients = when (recipientType) { - Message.RecipientType.TO -> recipientsTo - Message.RecipientType.CC -> recipientsCc - Message.RecipientType.BCC -> recipientsBcc - else -> throw InvalidObjectException( - "unknown RecipientType: $recipientType" - ) - } + fun addRecipient( + recipientType: Message.RecipientType, + email: CharSequence + ) { + viewModelScope.launch { + val normalizedEmail = email.toString().lowercase() + val existingRecipient = roomDatabase.recipientDao() + .getRecipientWithPubKeysByEmailSuspend(normalizedEmail) + ?: if (normalizedEmail.isValidEmail()) { + roomDatabase.recipientDao().insertSuspend(RecipientEntity(email = normalizedEmail)) + roomDatabase.recipientDao().getRecipientWithPubKeysByEmailSuspend(normalizedEmail) + } else null - existingRecipients.clear() - existingRecipients.putAll( - list.associateBy( - { it.recipient.email }, - { Recipient(recipientType, it) }) - ) + existingRecipient?.let { + val recipientInfo = RecipientInfo(recipientType, it) + when (recipientType) { + Message.RecipientType.TO -> recipientsToMutableStateFlow + Message.RecipientType.CC -> recipientsCcMutableStateFlow + Message.RecipientType.BCC -> recipientsBccMutableStateFlow + else -> throw InvalidObjectException("unknown RecipientType: $recipientType") + }.update { map -> + map.toMutableMap().apply { put(normalizedEmail, RecipientInfo(recipientType, it)) } + } - notifyDataChanges(recipientType, existingRecipients) + recipientLookUpManager.enqueue(recipientInfo) + } + } } fun removeRecipient( recipientType: Message.RecipientType, - recipientWithPubKeys: RecipientWithPubKeys + recipientEmail: String ) { - val existingRecipients = when (recipientType) { - Message.RecipientType.TO -> recipientsTo - Message.RecipientType.CC -> recipientsCc - Message.RecipientType.BCC -> recipientsBcc - else -> throw InvalidObjectException( - "unknown RecipientType: $recipientType" - ) + viewModelScope.launch { + val normalizedEmail = recipientEmail.lowercase() + + when (recipientType) { + Message.RecipientType.TO -> recipientsToMutableStateFlow + Message.RecipientType.CC -> recipientsCcMutableStateFlow + Message.RecipientType.BCC -> recipientsBccMutableStateFlow + else -> throw InvalidObjectException("unknown RecipientType: $recipientType") + }.update { map -> + map.toMutableMap().apply { remove(normalizedEmail) } + } + + recipientLookUpManager.dequeue(normalizedEmail) } + } - existingRecipients.remove(recipientWithPubKeys.recipient.email) - notifyDataChanges(recipientType, existingRecipients) + fun reCacheRecipient( + recipientType: Message.RecipientType, + email: CharSequence + ) { + viewModelScope.launch { + val normalizedEmail = email.toString().lowercase() + val existingRecipientWithPubKeys = roomDatabase.recipientDao() + .getRecipientWithPubKeysByEmailSuspend(normalizedEmail) ?: return@launch + val existingRecipientInfo = when (recipientType) { + Message.RecipientType.TO -> recipientsToMutableStateFlow + Message.RecipientType.CC -> recipientsCcMutableStateFlow + Message.RecipientType.BCC -> recipientsBccMutableStateFlow + else -> throw InvalidObjectException("unknown RecipientType: $recipientType") + }.value[normalizedEmail] ?: return@launch + when (recipientType) { + Message.RecipientType.TO -> recipientsToMutableStateFlow + Message.RecipientType.CC -> recipientsCcMutableStateFlow + Message.RecipientType.BCC -> recipientsBccMutableStateFlow + else -> throw InvalidObjectException("unknown RecipientType: $recipientType") + }.update { map -> + map.toMutableMap().apply { + replace( + normalizedEmail, + existingRecipientInfo.copy(recipientWithPubKeys = existingRecipientWithPubKeys) + ) + } + } + } } - private fun notifyDataChanges( + private fun replaceRecipient( recipientType: Message.RecipientType, - recipients: MutableMap + recipientInfo: RecipientInfo ) { - when (recipientType) { - Message.RecipientType.TO -> recipientsToMutableStateFlow - Message.RecipientType.CC -> recipientsCcMutableStateFlow - Message.RecipientType.BCC -> recipientsBccMutableStateFlow - else -> throw InvalidObjectException( - "Attempt to resolve unknown RecipientType: $recipientType" - ) - }.value = recipients.values.toList() + viewModelScope.launch { + val normalizedEmail = recipientInfo.recipientWithPubKeys.recipient.email + when (recipientType) { + Message.RecipientType.TO -> recipientsToMutableStateFlow + Message.RecipientType.CC -> recipientsCcMutableStateFlow + Message.RecipientType.BCC -> recipientsBccMutableStateFlow + else -> throw InvalidObjectException("unknown RecipientType: $recipientType") + }.update { map -> + map.toMutableMap().apply { replace(normalizedEmail, recipientInfo) } + } + } } - data class Recipient( - val recipientType: Message.RecipientType, - val recipientWithPubKeys: RecipientWithPubKeys, - val creationTime: Long = System.currentTimeMillis() - ) + fun callLookUpForMissedPubKeys() { + viewModelScope.launch { + allRecipients.forEach { entry -> + recipientLookUpManager.enqueue(entry.value) + } + } + } + + class RecipientLookUpManager( + private val application: Application, + private val roomDatabase: FlowCryptRoomDatabase, + private val viewModelScope: CoroutineScope, + private val updateListener: (recipientInfo: RecipientInfo) -> Unit + ) { + private val apiRepository: ApiRepository = FlowcryptApiRepository() + private val lookUpCandidates = ConcurrentHashMap() + private val recipientsSessionCache = ConcurrentHashMap() + + @OptIn(ExperimentalCoroutinesApi::class) + private val lookUpLimitedParallelismDispatcher = + Dispatchers.IO.limitedParallelism(PARALLELISM_COUNT) + + suspend fun enqueue(recipientInfo: RecipientInfo) = withContext(Dispatchers.IO) { + viewModelScope.launch { + val email = recipientInfo.recipientWithPubKeys.recipient.email + if (recipientsSessionCache.containsKey(email)) { + //we return a value from the session cache + updateListener.invoke( + recipientInfo.copy( + isUpdating = false, + recipientWithPubKeys = requireNotNull(recipientsSessionCache[email]) + ) + ) + } else { + lookUpCandidates[email] = recipientInfo + if (!recipientInfo.isUpdating) { + updateListener.invoke(recipientInfo.copy(isUpdating = true)) + } + try { + val recipientWithPubKeysAfterLookUp = lookUp(email) + dequeue(email) + if (recipientWithPubKeysAfterLookUp.hasUsablePubKey()) { + recipientsSessionCache[email] = recipientWithPubKeysAfterLookUp + } + updateListener.invoke( + recipientInfo.copy( + isUpdating = false, + recipientWithPubKeys = recipientWithPubKeysAfterLookUp + ) + ) + } catch (e: Exception) { + e.printStackTrace() + updateListener.invoke(recipientInfo.copy(isUpdating = false)) + } + } + } + } + + private suspend fun lookUp(email: String): RecipientWithPubKeys = withContext(Dispatchers.IO) { + val emailLowerCase = email.lowercase() + var cachedRecipientWithPubKeys = getCachedRecipientWithPubKeys(emailLowerCase) + if (cachedRecipientWithPubKeys == null) { + roomDatabase.recipientDao().insertSuspend(RecipientEntity(email = emailLowerCase)) + cachedRecipientWithPubKeys = + roomDatabase.recipientDao().getRecipientWithPubKeysByEmailSuspend(emailLowerCase) + } + + getPublicKeysFromRemoteServersInternal(email = emailLowerCase)?.let { pgpKeyDetailsList -> + cachedRecipientWithPubKeys?.let { recipientWithPubKeys -> + updateCachedInfoWithPubKeysFromLookUp( + recipientWithPubKeys, + pgpKeyDetailsList + ) + } + } + cachedRecipientWithPubKeys = getCachedRecipientWithPubKeys(emailLowerCase) + + return@withContext requireNotNull(cachedRecipientWithPubKeys) + } + + fun dequeue(email: String) { + lookUpCandidates.remove(email) + } + + private suspend fun getCachedRecipientWithPubKeys(emailLowerCase: String): RecipientWithPubKeys? = + withContext(Dispatchers.IO) { + val cachedRecipientWithPubKeys = roomDatabase.recipientDao() + .getRecipientWithPubKeysByEmailSuspend(emailLowerCase) ?: return@withContext null + + for (publicKeyEntity in cachedRecipientWithPubKeys.publicKeys) { + try { + val result = PgpKey.parseKeys(publicKeyEntity.publicKey).pgpKeyDetailsList + publicKeyEntity.pgpKeyDetails = result.firstOrNull() + } catch (e: Exception) { + e.printStackTrace() + publicKeyEntity.isNotUsable = true + } + } + return@withContext cachedRecipientWithPubKeys + } + + private suspend fun getPublicKeysFromRemoteServersInternal(email: String): + List? = withContext(Dispatchers.IO) { + try { + val activeAccount = roomDatabase.accountDao().getActiveAccountSuspend() + if (!lookUpCandidates.containsKey(email)) { + return@withContext null + } + val response = pubLookup(email, activeAccount) + + when (response.status) { + Result.Status.SUCCESS -> { + val pubKeyString = response.data?.pubkey + if (pubKeyString?.isNotEmpty() == true) { + val parsedResult = PgpKey.parseKeys(pubKeyString).pgpKeyDetailsList + if (parsedResult.isNotEmpty()) { + return@withContext parsedResult + } + } + } + + Result.Status.ERROR -> { + throw ApiException( + response.data?.apiError ?: ApiError( + code = -1, + msg = "Unknown API error" + ) + ) + } + + else -> { + throw response.exception ?: java.lang.Exception() + } + } + } catch (e: IOException) { + e.printStackTrace() + } + + null + } + + private suspend fun pubLookup( + email: String, + activeAccount: AccountEntity? + ): Result = withContext(lookUpLimitedParallelismDispatcher) { + return@withContext apiRepository.pubLookup( + context = application, + email = email, + orgRules = activeAccount?.clientConfiguration + ) + } + + private suspend fun updateCachedInfoWithPubKeysFromLookUp( + cachedRecipientEntity: RecipientWithPubKeys, fetchedPgpKeyDetailsList: List + ) = withContext(Dispatchers.IO) { + val email = cachedRecipientEntity.recipient.email + val uniqueMapOfFetchedPubKeys = + deduplicateFetchedPubKeysByFingerprint(fetchedPgpKeyDetailsList) + + val deDuplicatedListOfFetchedPubKeys = uniqueMapOfFetchedPubKeys.values + for (fetchedPgpKeyDetails in deDuplicatedListOfFetchedPubKeys) { + if (!fetchedPgpKeyDetails.usableForEncryption) { + //we skip a key that is not usable for encryption + continue + } + + val existingPublicKeyEntity = cachedRecipientEntity.publicKeys.firstOrNull { + it.fingerprint == fetchedPgpKeyDetails.fingerprint + } + val existingPgpKeyDetails = existingPublicKeyEntity?.pgpKeyDetails + if (existingPgpKeyDetails != null) { + val isExistingKeyRevoked = existingPgpKeyDetails.isRevoked + if (!isExistingKeyRevoked && fetchedPgpKeyDetails.isNewerThan(existingPgpKeyDetails)) { + roomDatabase.pubKeyDao().updateSuspend( + existingPublicKeyEntity.copy(publicKey = fetchedPgpKeyDetails.publicKey.toByteArray()) + ) + } + } else { + roomDatabase.pubKeyDao() + .insertWithReplaceSuspend(fetchedPgpKeyDetails.toPublicKeyEntity(email)) + } + } + } + + private fun deduplicateFetchedPubKeysByFingerprint( + fetchedPgpKeyDetailsList: List + ): Map { + val uniqueMapOfFetchedPubKeys = mutableMapOf() + + for (fetchedPgpKeyDetails in fetchedPgpKeyDetailsList) { + val fetchedFingerprint = fetchedPgpKeyDetails.fingerprint + val alreadyEncounteredFetchedPgpKeyDetails = uniqueMapOfFetchedPubKeys[fetchedFingerprint] + if (alreadyEncounteredFetchedPgpKeyDetails == null) { + uniqueMapOfFetchedPubKeys[fetchedFingerprint] = fetchedPgpKeyDetails + } else { + if (fetchedPgpKeyDetails.isNewerThan(alreadyEncounteredFetchedPgpKeyDetails)) { + uniqueMapOfFetchedPubKeys[fetchedFingerprint] = fetchedPgpKeyDetails + } + } + } + + return uniqueMapOfFetchedPubKeys + } + + companion object { + const val PARALLELISM_COUNT = 10 + } + } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/RecipientsAutoCompleteViewModel.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/RecipientsAutoCompleteViewModel.kt new file mode 100644 index 0000000000..daadf20909 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/RecipientsAutoCompleteViewModel.kt @@ -0,0 +1,57 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.jetpack.viewmodel + +import android.app.Application +import androidx.lifecycle.viewModelScope +import com.flowcrypt.email.api.retrofit.response.base.Result +import com.flowcrypt.email.database.entity.relation.RecipientWithPubKeys +import com.flowcrypt.email.util.coroutines.runners.ControlledRunner +import jakarta.mail.Message +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * @author Denis Bondarenko + * Date: 7/12/22 + * Time: 9:22 AM + * E-mail: DenBond7@gmail.com + */ +class RecipientsAutoCompleteViewModel(application: Application) : RoomBasicViewModel(application) { + private val controlledRunnerForAutoCompleteResult = + ControlledRunner>() + + private val autoCompleteResultMutableStateFlow: MutableStateFlow> = + MutableStateFlow(Result.none()) + val autoCompleteResultStateFlow: StateFlow> = + autoCompleteResultMutableStateFlow.asStateFlow() + + fun updateAutoCompleteResults(recipientType: Message.RecipientType, email: String) { + viewModelScope.launch { + autoCompleteResultMutableStateFlow.value = Result.loading() + autoCompleteResultMutableStateFlow.value = + controlledRunnerForAutoCompleteResult.cancelPreviousThenRun { + val autoCompleteResult = roomDatabase.recipientDao() + .findMatchingRecipients(if (email.isEmpty()) "" else "%$email%") + return@cancelPreviousThenRun Result.success( + AutoCompleteResults( + recipientType = recipientType, + pattern = email, + results = autoCompleteResult + ) + ) + } + } + } + + data class AutoCompleteResults( + val recipientType: Message.RecipientType, + val pattern: String, + val results: List + ) +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/CreateMessageFragment.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/CreateMessageFragment.kt index c6e4a2c3ac..5b2a87a95d 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/CreateMessageFragment.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/CreateMessageFragment.kt @@ -5,7 +5,6 @@ package com.flowcrypt.email.ui.activity.fragment -import android.annotation.SuppressLint import android.app.Activity import android.content.ContentResolver import android.content.Context @@ -15,20 +14,15 @@ import android.os.Bundle import android.text.format.Formatter import android.util.Log import android.view.ContextMenu -import android.view.Gravity import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem -import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter -import android.widget.FilterQueryProvider -import android.widget.FrameLayout import android.widget.ListView -import android.widget.ProgressBar import android.widget.Spinner import android.widget.TextView import android.widget.Toast @@ -39,14 +33,14 @@ import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat import androidx.core.view.MenuHost import androidx.core.view.MenuProvider -import androidx.core.view.isVisible import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.flowcrypt.email.Constants import com.flowcrypt.email.R import com.flowcrypt.email.api.email.EmailUtil @@ -55,7 +49,6 @@ import com.flowcrypt.email.api.email.model.AttachmentInfo import com.flowcrypt.email.api.email.model.ExtraActionInfo import com.flowcrypt.email.api.email.model.OutgoingMessageInfo import com.flowcrypt.email.api.retrofit.response.base.Result -import com.flowcrypt.email.database.FlowCryptRoomDatabase import com.flowcrypt.email.database.entity.AccountEntity import com.flowcrypt.email.database.entity.RecipientEntity import com.flowcrypt.email.database.entity.relation.RecipientWithPubKeys @@ -64,8 +57,8 @@ import com.flowcrypt.email.extensions.appBarLayout import com.flowcrypt.email.extensions.countingIdlingResource import com.flowcrypt.email.extensions.decrementSafely import com.flowcrypt.email.extensions.gone +import com.flowcrypt.email.extensions.hideKeyboard import com.flowcrypt.email.extensions.incrementSafely -import com.flowcrypt.email.extensions.invisible import com.flowcrypt.email.extensions.navController import com.flowcrypt.email.extensions.org.bouncycastle.openpgp.toPgpKeyDetails import com.flowcrypt.email.extensions.showChoosePublicKeyDialogFragment @@ -79,6 +72,7 @@ import com.flowcrypt.email.extensions.visibleOrGone import com.flowcrypt.email.jetpack.lifecycle.CustomAndroidViewModelFactory import com.flowcrypt.email.jetpack.viewmodel.AccountAliasesViewModel import com.flowcrypt.email.jetpack.viewmodel.ComposeMsgViewModel +import com.flowcrypt.email.jetpack.viewmodel.RecipientsAutoCompleteViewModel import com.flowcrypt.email.jetpack.viewmodel.RecipientsViewModel import com.flowcrypt.email.model.MessageEncryptionType import com.flowcrypt.email.model.MessageType @@ -88,22 +82,19 @@ import com.flowcrypt.email.ui.activity.fragment.base.BaseFragment import com.flowcrypt.email.ui.activity.fragment.dialog.ChoosePublicKeyDialogFragment import com.flowcrypt.email.ui.activity.fragment.dialog.FixNeedPassphraseIssueDialogFragment import com.flowcrypt.email.ui.activity.fragment.dialog.NoPgpFoundDialogFragment +import com.flowcrypt.email.ui.adapter.AutoCompleteResultRecyclerViewAdapter import com.flowcrypt.email.ui.adapter.FromAddressesAdapter -import com.flowcrypt.email.ui.adapter.RecipientAdapter -import com.flowcrypt.email.ui.widget.CustomChipSpanChipCreator -import com.flowcrypt.email.ui.widget.PGPContactChipSpan -import com.flowcrypt.email.ui.widget.PgpContactsNachoTextView +import com.flowcrypt.email.ui.adapter.RecipientChipRecyclerViewAdapter +import com.flowcrypt.email.ui.adapter.recyclerview.itemdecoration.MarginItemDecoration import com.flowcrypt.email.util.FileAndDirectoryUtils import com.flowcrypt.email.util.GeneralUtil import com.flowcrypt.email.util.UIUtil import com.flowcrypt.email.util.exception.ExceptionUtil +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexboxLayoutManager +import com.google.android.flexbox.JustifyContent import com.google.android.gms.common.util.CollectionUtils import com.google.android.material.snackbar.Snackbar -import com.hootsuite.nachos.NachoTextView -import com.hootsuite.nachos.chip.Chip -import com.hootsuite.nachos.terminator.ChipTerminatorHandler -import com.hootsuite.nachos.tokenizer.SpanChipTokenizer -import com.hootsuite.nachos.validator.ChipifyingNachoValidator import jakarta.mail.Message import jakarta.mail.internet.InternetAddress import org.apache.commons.io.FileUtils @@ -112,8 +103,10 @@ import org.pgpainless.key.OpenPgpV4Fingerprint import org.pgpainless.util.Passphrase import java.io.File import java.io.IOException +import java.io.InvalidObjectException import java.util.regex.Pattern + /** * This fragment describe a logic of sent an encrypted or standard message. * @@ -123,9 +116,7 @@ import java.util.regex.Pattern * E-mail: DenBond7@gmail.com */ class CreateMessageFragment : BaseFragment(), - View.OnFocusChangeListener, - AdapterView.OnItemSelectedListener, - View.OnClickListener, PgpContactsNachoTextView.OnChipLongClickListener { + AdapterView.OnItemSelectedListener, View.OnClickListener { override fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?) = FragmentCreateMessageBinding.inflate(inflater, container, false) @@ -135,6 +126,7 @@ class CreateMessageFragment : BaseFragment(), private val args by navArgs() private val accountAliasesViewModel: AccountAliasesViewModel by viewModels() private val recipientsViewModel: RecipientsViewModel by viewModels() + private val recipientsAutoCompleteViewModel: RecipientsAutoCompleteViewModel by viewModels() private val composeMsgViewModel: ComposeMsgViewModel by viewModels { object : CustomAndroidViewModelFactory(requireActivity().application) { @Suppress("UNCHECKED_CAST") @@ -149,6 +141,73 @@ class CreateMessageFragment : BaseFragment(), uri?.let { addAttachmentInfoFromUri(it) } } + private val onChipsListener = object : RecipientChipRecyclerViewAdapter.OnChipsListener { + override fun onEmailAddressTyped(recipientType: Message.RecipientType, email: CharSequence) { + recipientsAutoCompleteViewModel.updateAutoCompleteResults(recipientType, email.toString()) + } + + override fun onEmailAddressAdded(recipientType: Message.RecipientType, email: CharSequence) { + composeMsgViewModel.addRecipient(recipientType, email) + } + + override fun onChipDeleted( + recipientType: Message.RecipientType, + recipientInfo: RecipientChipRecyclerViewAdapter.RecipientInfo + ) { + val email = recipientInfo.recipientWithPubKeys.recipient.email + composeMsgViewModel.removeRecipient(recipientType, email) + } + + override fun onAddFieldFocusChanged(recipientType: Message.RecipientType, hasFocus: Boolean) { + val recipients = when (recipientType) { + Message.RecipientType.TO -> composeMsgViewModel.recipientsToStateFlow.value + Message.RecipientType.CC -> composeMsgViewModel.recipientsCcStateFlow.value + Message.RecipientType.BCC -> composeMsgViewModel.recipientsBccStateFlow.value + else -> throw InvalidObjectException("unknown RecipientType: $recipientType") + } + updateChipAdapter(recipientType, recipients) + } + } + + private val toRecipientsChipRecyclerViewAdapter = RecipientChipRecyclerViewAdapter( + recipientType = Message.RecipientType.TO, + onChipsListener = onChipsListener + ) + + private val ccRecipientsChipRecyclerViewAdapter = RecipientChipRecyclerViewAdapter( + recipientType = Message.RecipientType.CC, + onChipsListener = onChipsListener + ) + + private val bccRecipientsChipRecyclerViewAdapter = RecipientChipRecyclerViewAdapter( + recipientType = Message.RecipientType.BCC, + onChipsListener = onChipsListener + ) + + private val onAutoCompleteResultListener = + object : AutoCompleteResultRecyclerViewAdapter.OnResultListener { + override fun onResultClick( + recipientType: Message.RecipientType, + recipientWithPubKeys: RecipientWithPubKeys + ) { + when (recipientType) { + Message.RecipientType.TO -> toRecipientsChipRecyclerViewAdapter.resetTypedText = true + Message.RecipientType.CC -> ccRecipientsChipRecyclerViewAdapter.resetTypedText = true + Message.RecipientType.BCC -> bccRecipientsChipRecyclerViewAdapter.resetTypedText = true + else -> throw InvalidObjectException("unknown RecipientType: $recipientType") + } + toRecipientsChipRecyclerViewAdapter.resetTypedText = true + composeMsgViewModel.addRecipient(recipientType, recipientWithPubKeys.recipient.email) + } + } + + private val toAutoCompleteResultRecyclerViewAdapter = + AutoCompleteResultRecyclerViewAdapter(Message.RecipientType.TO, onAutoCompleteResultListener) + private val ccAutoCompleteResultRecyclerViewAdapter = + AutoCompleteResultRecyclerViewAdapter(Message.RecipientType.CC, onAutoCompleteResultListener) + private val bccAutoCompleteResultRecyclerViewAdapter = + AutoCompleteResultRecyclerViewAdapter(Message.RecipientType.BCC, onAutoCompleteResultListener) + private val attachments: MutableList = mutableListOf() private var folderType: FoldersManager.FolderType? = null private var fromAddressesAdapter: FromAddressesAdapter? = null @@ -156,9 +215,6 @@ class CreateMessageFragment : BaseFragment(), private var extraActionInfo: ExtraActionInfo? = null private var nonEncryptedHintView: View? = null - private var isUpdateToCompleted = true - private var isUpdateCcCompleted = true - private var isUpdateBccCompleted = true private var isIncomingMsgInfoUsed: Boolean = false private var isMsgSentToQueue: Boolean = false private var originalColor: Int = 0 @@ -185,9 +241,9 @@ class CreateMessageFragment : BaseFragment(), updateActionBar() initViews() setupComposeMsgViewModel() + setupRecipientsAutoCompleteViewModel() setupAccountAliasesViewModel() setupPrivateKeysViewModel() - setupRecipientsViewModel() subscribeToSetWebPortalPassword() subscribeToSelectRecipients() @@ -198,7 +254,7 @@ class CreateMessageFragment : BaseFragment(), val isEncryptedMode = composeMsgViewModel.msgEncryptionType === MessageEncryptionType.ENCRYPTED if (args.incomingMessageInfo != null && GeneralUtil.isConnected(context) && isEncryptedMode) { - updateRecipients() + composeMsgViewModel.callLookUpForMissedPubKeys() } } @@ -252,33 +308,29 @@ class CreateMessageFragment : BaseFragment(), R.id.menuActionSend -> { snackBar?.dismiss() - if (isUpdateToCompleted && isUpdateCcCompleted && isUpdateBccCompleted) { - UIUtil.hideSoftInput(context, view) - if (isDataCorrect()) { - if (composeMsgViewModel.msgEncryptionType == MessageEncryptionType.ENCRYPTED) { - val keysStorage = KeysStorageImpl.getInstance(requireContext()) - val senderEmail = binding?.editTextFrom?.text.toString() - val usableSecretKey = - keysStorage.getFirstUsableForEncryptionPGPSecretKeyRing(senderEmail) - if (usableSecretKey != null) { - val openPgpV4Fingerprint = OpenPgpV4Fingerprint(usableSecretKey) - val fingerprint = openPgpV4Fingerprint.toString() - val passphrase = keysStorage.getPassphraseByFingerprint(fingerprint) - if (passphrase?.isEmpty == true) { - showNeedPassphraseDialog(listOf(fingerprint)) - return true - } - } else { - showInfoDialog(dialogMsg = getString(R.string.no_private_keys_suitable_for_encryption)) + view?.hideKeyboard() + if (isDataCorrect()) { + if (composeMsgViewModel.msgEncryptionType == MessageEncryptionType.ENCRYPTED) { + val keysStorage = KeysStorageImpl.getInstance(requireContext()) + val senderEmail = binding?.editTextFrom?.text.toString() + val usableSecretKey = + keysStorage.getFirstUsableForEncryptionPGPSecretKeyRing(senderEmail) + if (usableSecretKey != null) { + val openPgpV4Fingerprint = OpenPgpV4Fingerprint(usableSecretKey) + val fingerprint = openPgpV4Fingerprint.toString() + val passphrase = keysStorage.getPassphraseByFingerprint(fingerprint) + if (passphrase?.isEmpty == true) { + showNeedPassphraseDialog(listOf(fingerprint)) return true } + } else { + showInfoDialog(dialogMsg = getString(R.string.no_private_keys_suitable_for_encryption)) + return true } - - sendMsg() - isMsgSentToQueue = true } - } else { - toast(R.string.please_wait_while_information_about_recipients_will_be_updated) + + sendMsg() + isMsgSentToQueue = true } return true } @@ -337,44 +389,6 @@ class CreateMessageFragment : BaseFragment(), } } - override fun onFocusChange(v: View, hasFocus: Boolean) { - when (v.id) { - R.id.editTextRecipientTo -> runUpdateActionForRecipients( - Message.RecipientType.TO, hasFocus, (v as TextView).text.isEmpty() - ) - - R.id.editTextRecipientCc -> runUpdateActionForRecipients( - Message.RecipientType.CC, hasFocus, (v as TextView).text.isEmpty() - ) - - R.id.editTextRecipientBcc -> runUpdateActionForRecipients( - Message.RecipientType.BCC, hasFocus, (v as TextView).text.isEmpty() - ) - - R.id.editTextEmailSubject, R.id.editTextEmailMessage -> if (hasFocus) { - var isExpandButtonNeeded = false - if (binding?.editTextRecipientCc?.text?.isEmpty() == true) { - binding?.layoutCc?.gone() - isExpandButtonNeeded = true - } - - if (binding?.editTextRecipientBcc?.text?.isEmpty() == true) { - binding?.layoutBcc?.gone() - isExpandButtonNeeded = true - } - - if (isExpandButtonNeeded) { - binding?.imageButtonAdditionalRecipientsVisibility?.visible() - val layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT - ) - layoutParams.gravity = Gravity.TOP or Gravity.END - binding?.progressBarAndButtonLayout?.layoutParams = layoutParams - } - } - } - } - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { when (parent?.id) { R.id.spinnerFrom -> { @@ -400,19 +414,6 @@ class CreateMessageFragment : BaseFragment(), binding?.spinnerFrom?.performClick() } - R.id.imageButtonAdditionalRecipientsVisibility -> { - binding?.layoutCc?.visible() - binding?.layoutBcc?.visible() - val layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.MATCH_PARENT - ) - layoutParams.gravity = Gravity.TOP or Gravity.END - - binding?.progressBarAndButtonLayout?.layoutParams = layoutParams - v.visibility = View.GONE - binding?.editTextRecipientCc?.requestFocus() - } - R.id.iBShowQuotedText -> { val currentCursorPosition = binding?.editTextEmailMessage?.selectionStart ?: 0 if (binding?.editTextEmailMessage?.text?.isNotEmpty() == true) { @@ -426,8 +427,6 @@ class CreateMessageFragment : BaseFragment(), } } - override fun onChipLongClick(nachoTextView: NachoTextView, chip: Chip, event: MotionEvent) {} - override fun onAccountInfoRefreshed(accountEntity: AccountEntity?) { super.onAccountInfoRefreshed(accountEntity) accountEntity?.email?.let { email -> @@ -443,18 +442,7 @@ class CreateMessageFragment : BaseFragment(), when (messageEncryptionType) { MessageEncryptionType.ENCRYPTED -> { emailMassageHint = getString(R.string.prompt_compose_security_email) - binding?.editTextRecipientTo?.onFocusChangeListener?.onFocusChange( - binding?.editTextRecipientTo, - false - ) - binding?.editTextRecipientCc?.onFocusChangeListener?.onFocusChange( - binding?.editTextRecipientCc, - false - ) - binding?.editTextRecipientBcc?.onFocusChangeListener?.onFocusChange( - binding?.editTextRecipientBcc, - false - ) + composeMsgViewModel.callLookUpForMissedPubKeys() fromAddressesAdapter?.setUseKeysInfo(true) val colorGray = UIUtil.getColor(requireContext(), R.color.gray) @@ -469,9 +457,6 @@ class CreateMessageFragment : BaseFragment(), MessageEncryptionType.STANDARD -> { emailMassageHint = getString(R.string.prompt_compose_standard_email) - isUpdateToCompleted = true - isUpdateCcCompleted = true - isUpdateBccCompleted = true fromAddressesAdapter?.setUseKeysInfo(false) binding?.editTextFrom?.setTextColor(originalColor) } @@ -567,84 +552,6 @@ class CreateMessageFragment : BaseFragment(), } } - private fun updateRecipients() { - binding?.editTextRecipientTo?.chipAndTokenValues?.let { - recipientsViewModel.fetchAndUpdateInfoAboutRecipients( - Message.RecipientType.TO, - it - ) - } - - if (binding?.layoutCc?.isVisible == true) { - binding?.editTextRecipientCc?.chipAndTokenValues?.let { - recipientsViewModel.fetchAndUpdateInfoAboutRecipients( - Message.RecipientType.CC, - it - ) - } - } else { - binding?.editTextRecipientCc?.setText(null as CharSequence?) - composeMsgViewModel.replaceRecipients(Message.RecipientType.CC, emptyList()) - } - - if (binding?.layoutBcc?.isVisible == true) { - binding?.editTextRecipientBcc?.chipAndTokenValues?.let { - recipientsViewModel.fetchAndUpdateInfoAboutRecipients( - Message.RecipientType.BCC, - it - ) - } - } else { - binding?.editTextRecipientBcc?.setText(null as CharSequence?) - composeMsgViewModel.replaceRecipients(Message.RecipientType.BCC, emptyList()) - } - } - - /** - * Run an action to update information about some [RecipientWithPubKeys]s. - * - * @param type A type of recipients - * @param hasFocus A value which indicates the view focus. - * @return A modified recipients list. - */ - private fun runUpdateActionForRecipients( - type: Message.RecipientType, - hasFocus: Boolean, - isEmpty: Boolean - ) { - if (composeMsgViewModel.msgEncryptionType === MessageEncryptionType.ENCRYPTED) { - if (!hasFocus && isAdded) { - fetchDetailsAboutRecipients(type) - } - } - - if (isEmpty) { - composeMsgViewModel.replaceRecipients(type, emptyList()) - } - } - - private fun fetchDetailsAboutRecipients(type: Message.RecipientType) { - when (type) { - Message.RecipientType.TO -> { - binding?.editTextRecipientTo?.chipAndTokenValues?.let { - recipientsViewModel.fetchAndUpdateInfoAboutRecipients(Message.RecipientType.TO, it) - } - } - - Message.RecipientType.CC -> { - binding?.editTextRecipientCc?.chipAndTokenValues?.let { - recipientsViewModel.fetchAndUpdateInfoAboutRecipients(Message.RecipientType.CC, it) - } - } - - Message.RecipientType.BCC -> { - binding?.editTextRecipientBcc?.chipAndTokenValues?.let { - recipientsViewModel.fetchAndUpdateInfoAboutRecipients(Message.RecipientType.BCC, it) - } - } - } - } - /** * Prepare an alias for the reply. Will be used the email address that the email was received. Will be used the * first found matched email. @@ -713,8 +620,8 @@ class CreateMessageFragment : BaseFragment(), * Check that all recipients are usable. */ private fun hasUnusableRecipient(): Boolean { - for (recipient in composeMsgViewModel.recipientWithPubKeys) { - val recipientWithPubKeys = recipient.recipientWithPubKeys + for (recipient in composeMsgViewModel.allRecipients) { + val recipientWithPubKeys = recipient.value.recipientWithPubKeys if (!recipientWithPubKeys.hasAtLeastOnePubKey()) { return if (isPasswordProtectedFunctionalityEnabled()) { if (composeMsgViewModel.webPortalPasswordStateFlow.value.isEmpty()) { @@ -746,59 +653,6 @@ class CreateMessageFragment : BaseFragment(), return false } - /** - * This method does update chips in the recipients field. - * - * @param view A view which contains input [RecipientWithPubKeys](s). - * @param list The input [RecipientWithPubKeys](s) - */ - private fun updateChips( - view: PgpContactsNachoTextView?, - list: List? - ) { - view ?: return - val pgpContactChipSpans = view.text.getSpans(0, view.length(), PGPContactChipSpan::class.java) - - if (pgpContactChipSpans.isNotEmpty()) { - for (recipientWithPubKeys in list ?: emptyList()) { - for (pgpContactChipSpan in pgpContactChipSpans) { - if (recipientWithPubKeys.recipient.email.equals( - pgpContactChipSpan.text.toString(), ignoreCase = true - ) - ) { - pgpContactChipSpan.hasAtLeastOnePubKey = recipientWithPubKeys.hasAtLeastOnePubKey() - pgpContactChipSpan.hasNotExpiredPubKey = recipientWithPubKeys.hasNotExpiredPubKey() - pgpContactChipSpan.hasUsablePubKey = recipientWithPubKeys.hasUsablePubKey() - pgpContactChipSpan.hasNotRevokedPubKey = recipientWithPubKeys.hasNotRevokedPubKey() - break - } - } - } - view.invalidateChips() - } - } - - /** - * Init an input [NachoTextView] using custom settings. - * - * @param pgpContactsNachoTextView An input [NachoTextView] - */ - private fun initChipsView(pgpContactsNachoTextView: PgpContactsNachoTextView?) { - pgpContactsNachoTextView?.setNachoValidator(ChipifyingNachoValidator()) - pgpContactsNachoTextView?.setIllegalCharacterIdentifier { character -> character == ',' } - pgpContactsNachoTextView?.addChipTerminator( - ' ', ChipTerminatorHandler - .BEHAVIOR_CHIPIFY_TO_TERMINATOR - ) - pgpContactsNachoTextView?.chipTokenizer = SpanChipTokenizer( - requireContext(), - CustomChipSpanChipCreator(requireContext()), PGPContactChipSpan::class.java - ) - pgpContactsNachoTextView?.setAdapter(prepareRecipientsAdapter()) - pgpContactsNachoTextView?.onFocusChangeListener = this - pgpContactsNachoTextView?.setListener(this) - } - private fun hasExternalStorageUris(attachmentInfoList: List?): Boolean { attachmentInfoList?.let { for (att in it) { @@ -810,39 +664,8 @@ class CreateMessageFragment : BaseFragment(), return false } - /** - * Remove the current [RecipientWithPubKeys] from recipients. - * - * @param recipientWithPubKeys The [RecipientWithPubKeys] which will be removed. - * @param pgpContactsNachoTextView The [NachoTextView] which contains the delete candidate. - */ - private fun removeRecipientWithPubKey( - recipientWithPubKeys: RecipientWithPubKeys, pgpContactsNachoTextView: PgpContactsNachoTextView?, - recipientType: Message.RecipientType - ) { - val chipTokenizer = pgpContactsNachoTextView?.chipTokenizer - pgpContactsNachoTextView?.allChips?.let { - for (chip in it) { - if (recipientWithPubKeys.recipient.email.equals( - chip.text.toString(), - ignoreCase = true - ) && chipTokenizer != null - ) { - chipTokenizer.deleteChip(chip, pgpContactsNachoTextView.text) - } - } - } - - composeMsgViewModel.removeRecipient(recipientType, recipientWithPubKeys) - } - - /** - * Init fragment views - */ private fun initViews() { - initChipsView(binding?.editTextRecipientTo) - initChipsView(binding?.editTextRecipientCc) - initChipsView(binding?.editTextRecipientBcc) + setupChips() binding?.spinnerFrom?.onItemSelectedListener = this binding?.spinnerFrom?.adapter = fromAddressesAdapter @@ -851,10 +674,31 @@ class CreateMessageFragment : BaseFragment(), binding?.imageButtonAliases?.setOnClickListener(this) - binding?.imageButtonAdditionalRecipientsVisibility?.setOnClickListener(this) + binding?.imageButtonAdditionalRecipientsVisibility?.setOnClickListener { + it.gone() + binding?.chipLayoutCc?.visible() + binding?.chipLayoutBcc?.visible() + } + + val onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + var isExpandButtonNeeded = false + if (composeMsgViewModel.recipientsCc.isEmpty()) { + binding?.chipLayoutCc?.gone() + isExpandButtonNeeded = true + } + + if (composeMsgViewModel.recipientsBcc.isEmpty()) { + binding?.chipLayoutBcc?.gone() + isExpandButtonNeeded = true + } + + binding?.imageButtonAdditionalRecipientsVisibility?.visibleOrGone(isExpandButtonNeeded) + } + } - binding?.editTextEmailSubject?.onFocusChangeListener = this - binding?.editTextEmailMessage?.onFocusChangeListener = this + binding?.editTextEmailSubject?.onFocusChangeListener = onFocusChangeListener + binding?.editTextEmailMessage?.onFocusChangeListener = onFocusChangeListener binding?.iBShowQuotedText?.setOnClickListener(this) binding?.btnSetWebPortalPassword?.setOnClickListener { navController?.navigate( @@ -866,6 +710,51 @@ class CreateMessageFragment : BaseFragment(), } } + private fun setupChips() { + setupChipsRecyclerView(binding?.recyclerViewChipsTo, toRecipientsChipRecyclerViewAdapter) + setupChipsRecyclerView(binding?.recyclerViewChipsCc, ccRecipientsChipRecyclerViewAdapter) + setupChipsRecyclerView(binding?.recyclerViewChipsBcc, bccRecipientsChipRecyclerViewAdapter) + + setupAutoCompleteResultRecyclerViewAdapter( + binding?.recyclerViewAutocompleteTo, + toAutoCompleteResultRecyclerViewAdapter + ) + + setupAutoCompleteResultRecyclerViewAdapter( + binding?.recyclerViewAutocompleteCc, + ccAutoCompleteResultRecyclerViewAdapter + ) + + setupAutoCompleteResultRecyclerViewAdapter( + binding?.recyclerViewAutocompleteBcc, + bccAutoCompleteResultRecyclerViewAdapter + ) + } + + private fun setupAutoCompleteResultRecyclerViewAdapter( + recyclerViewAutocompleteTo: RecyclerView?, + toAutoCompleteResultRecyclerViewAdapter: AutoCompleteResultRecyclerViewAdapter + ) { + recyclerViewAutocompleteTo?.layoutManager = LinearLayoutManager(context) + recyclerViewAutocompleteTo?.adapter = toAutoCompleteResultRecyclerViewAdapter + } + + private fun setupChipsRecyclerView( + recyclerView: RecyclerView?, + recipientChipRecyclerViewAdapter: RecipientChipRecyclerViewAdapter + ) { + recyclerView?.layoutManager = FlexboxLayoutManager(context).apply { + flexDirection = FlexDirection.ROW + justifyContent = JustifyContent.FLEX_START + } + recyclerView?.adapter = recipientChipRecyclerViewAdapter + recyclerView?.addItemDecoration( + MarginItemDecoration( + marginRight = resources.getDimensionPixelSize(R.dimen.default_margin_content_small) + ) + ) + } + private fun showContent() { UIUtil.exchangeViewVisibility(false, binding?.viewIdProgressView, binding?.scrollView) if ((args.incomingMessageInfo != null || extraActionInfo != null) && !isIncomingMsgInfoUsed) { @@ -888,12 +777,8 @@ class CreateMessageFragment : BaseFragment(), } else { if (args.incomingMessageInfo != null) { updateViewsFromIncomingMsgInfo() - binding?.editTextRecipientTo?.chipifyAllUnterminatedTokens() - binding?.editTextRecipientCc?.chipifyAllUnterminatedTokens() binding?.editTextEmailSubject?.setText( - prepareReplySubject( - args.incomingMessageInfo?.getSubject() ?: "" - ) + prepareReplySubject(args.incomingMessageInfo?.getSubject() ?: "") ) } @@ -904,24 +789,23 @@ class CreateMessageFragment : BaseFragment(), } private fun updateViewsFromExtraActionInfo() { - setupPgpFromExtraActionInfo( - binding?.editTextRecipientTo, - extraActionInfo?.toAddresses?.toTypedArray() - ) - setupPgpFromExtraActionInfo( - binding?.editTextRecipientCc, - extraActionInfo?.ccAddresses?.toTypedArray() - ) - setupPgpFromExtraActionInfo( - binding?.editTextRecipientBcc, - extraActionInfo?.bccAddresses?.toTypedArray() - ) + extraActionInfo?.toAddresses?.forEach { + composeMsgViewModel.addRecipient(Message.RecipientType.TO, it) + } + + extraActionInfo?.ccAddresses?.forEach { + composeMsgViewModel.addRecipient(Message.RecipientType.CC, it) + } + + extraActionInfo?.bccAddresses?.forEach { + composeMsgViewModel.addRecipient(Message.RecipientType.BCC, it) + } binding?.editTextEmailSubject?.setText(extraActionInfo?.subject) binding?.editTextEmailMessage?.setText(extraActionInfo?.body) - if (binding?.editTextRecipientTo?.text?.isEmpty() == true) { - binding?.editTextRecipientTo?.requestFocus() + if (extraActionInfo?.toAddresses?.isEmpty() == true) { + toRecipientsChipRecyclerViewAdapter.requestFocus() return } @@ -935,11 +819,6 @@ class CreateMessageFragment : BaseFragment(), } private fun updateViewsFromServiceInfo() { - binding?.editTextRecipientTo?.isFocusable = args.serviceInfo?.isToFieldEditable ?: false - binding?.editTextRecipientTo?.isFocusableInTouchMode = - args.serviceInfo?.isToFieldEditable ?: false - //todo-denbond7 Need to add a similar option for editTextRecipientCc and editTextRecipientBcc - binding?.editTextEmailSubject?.isFocusable = args.serviceInfo?.isSubjectEditable ?: false binding?.editTextEmailSubject?.isFocusableInTouchMode = args.serviceInfo?.isSubjectEditable ?: false @@ -963,8 +842,6 @@ class CreateMessageFragment : BaseFragment(), MessageType.FORWARD -> updateViewsIfFwdMode() } - - updateRecipients() } private fun updateViewsIfFwdMode() { @@ -1008,11 +885,15 @@ class CreateMessageFragment : BaseFragment(), private fun updateViewsIfReplyAllMode() { when (folderType) { FoldersManager.FolderType.SENT, FoldersManager.FolderType.OUTBOX -> { - binding?.editTextRecipientTo?.setText(prepareRecipients(args.incomingMessageInfo?.getTo())) + args.incomingMessageInfo?.getTo()?.forEach { + composeMsgViewModel.addRecipient(Message.RecipientType.TO, it.address) + } if (args.incomingMessageInfo?.getCc()?.isNotEmpty() == true) { - binding?.layoutCc?.visibility = View.VISIBLE - binding?.editTextRecipientCc?.append(prepareRecipients(args.incomingMessageInfo?.getCc())) + binding?.chipLayoutCc?.visibility = View.VISIBLE + args.incomingMessageInfo?.getCc()?.forEach { + composeMsgViewModel.addRecipient(Message.RecipientType.CC, it.address) + } } } @@ -1024,7 +905,9 @@ class CreateMessageFragment : BaseFragment(), args.incomingMessageInfo?.getReplyToWithoutOwnerAddress() ?: emptyList() } - binding?.editTextRecipientTo?.setText(prepareRecipients(toRecipients)) + toRecipients.forEach { + composeMsgViewModel.addRecipient(Message.RecipientType.TO, it.address) + } val ccSet = HashSet() @@ -1063,57 +946,43 @@ class CreateMessageFragment : BaseFragment(), val finalCcSet = ccSet.filter { fromAddress?.equals(it.address, true) != true } if (finalCcSet.isNotEmpty()) { - binding?.layoutCc?.visible() - val ccRecipients = prepareRecipients(finalCcSet) - binding?.editTextRecipientCc?.append(ccRecipients) + binding?.chipLayoutCc?.visible() + finalCcSet.forEach { + composeMsgViewModel.addRecipient(Message.RecipientType.CC, it.address) + } } } } - if (binding?.editTextRecipientTo?.text?.isNotEmpty() == true - || binding?.editTextRecipientCc?.text?.isNotEmpty() == true - ) { - binding?.editTextEmailMessage?.requestFocus() - binding?.editTextEmailMessage?.showKeyboard() - } + binding?.editTextEmailMessage?.requestFocus() + binding?.editTextEmailMessage?.showKeyboard() } private fun updateViewsIfReplyMode() { when (folderType) { FoldersManager.FolderType.SENT, FoldersManager.FolderType.OUTBOX -> { - binding?.editTextRecipientTo?.setText(prepareRecipients(args.incomingMessageInfo?.getTo())) + args.incomingMessageInfo?.getTo()?.forEach { + composeMsgViewModel.addRecipient(Message.RecipientType.TO, it.address) + } } - else -> binding?.editTextRecipientTo?.setText( - prepareRecipients( + else -> { + val recipients = if (args.incomingMessageInfo?.getReplyToWithoutOwnerAddress().isNullOrEmpty()) { args.incomingMessageInfo?.getTo() } else { args.incomingMessageInfo?.getReplyToWithoutOwnerAddress() } - ) - ) - } - if (binding?.editTextRecipientTo?.text?.isNotEmpty() == true) { - binding?.editTextEmailMessage?.requestFocus() - binding?.editTextEmailMessage?.showKeyboard() + recipients?.forEach { + composeMsgViewModel.addRecipient(Message.RecipientType.TO, it.address) + } + } } - } - private fun setupPgpFromExtraActionInfo( - pgpContactsNachoTextView: PgpContactsNachoTextView?, - addresses: Array? - ) { - if (addresses?.isNotEmpty() == true) { - pgpContactsNachoTextView?.setText(prepareRecipients(addresses)) - pgpContactsNachoTextView?.chipifyAllUnterminatedTokens() - pgpContactsNachoTextView?.onFocusChangeListener?.onFocusChange( - pgpContactsNachoTextView, - false - ) - } + binding?.editTextEmailMessage?.requestFocus() + binding?.editTextEmailMessage?.showKeyboard() } private fun prepareRecipientsLineForForwarding(recipients: List?): String { @@ -1148,66 +1017,6 @@ class CreateMessageFragment : BaseFragment(), ) } - private fun prepareRecipients(recipients: Array?): String { - val stringBuilder = StringBuilder() - if (recipients != null && recipients.isNotEmpty()) { - for (s in recipients) { - stringBuilder.append(s).append(" ") - } - } - - return stringBuilder.toString() - } - - private fun prepareRecipients(recipients: Collection?): String { - val stringBuilder = StringBuilder() - if (!CollectionUtils.isEmpty(recipients)) { - for (s in recipients!!) { - stringBuilder.append(s.address).append(" ") - } - } - - return stringBuilder.toString() - } - - /** - * Prepare a [RecipientAdapter] for the [NachoTextView] object. - * - * @return [RecipientAdapter] - */ - @SuppressLint("Recycle") - private fun prepareRecipientsAdapter(): RecipientAdapter { - val pgpContactAdapter = RecipientAdapter(requireContext(), null, true) - //setup a search contacts logic in the database - pgpContactAdapter.filterQueryProvider = FilterQueryProvider { constraint -> - val dao = FlowCryptRoomDatabase.getDatabase(requireContext()).recipientDao() - dao.getFilteredCursor("%$constraint%") - } - - return pgpContactAdapter - } - - /** - * Check if the given [pgpContactsNachoTextViews] List has an invalid email. - * - * @return boolean true - if has, otherwise false.. - */ - private fun hasInvalidEmail(vararg pgpContactsNachoTextViews: PgpContactsNachoTextView?): Boolean { - for (textView in pgpContactsNachoTextViews) { - val emails = textView?.chipAndTokenValues - if (emails != null) { - for (email in emails) { - if (!GeneralUtil.isEmailValid(email)) { - showInfoSnackbar(textView, getString(R.string.error_some_email_is_not_valid, email)) - textView.requestFocus() - return true - } - } - } - } - return false - } - /** * Check is attachment can be added to the current message. * @@ -1367,7 +1176,7 @@ class CreateMessageFragment : BaseFragment(), private fun setupPrivateKeysViewModel() { KeysStorageImpl.getInstance(requireContext()).secretKeyRingsLiveData - .observe(viewLifecycleOwner, { updateFromAddressAdapter(it) }) + .observe(viewLifecycleOwner) { updateFromAddressAdapter(it) } } private fun updateFromAddressAdapter(list: List) { @@ -1382,67 +1191,6 @@ class CreateMessageFragment : BaseFragment(), } } - private fun setupRecipientsViewModel() { - handleUpdatingRecipients( - recipientsViewModel.recipientsToLiveData, - Message.RecipientType.TO, - binding?.progressBarTo - ) { - isUpdateToCompleted = it - } - - handleUpdatingRecipients( - recipientsViewModel.recipientsCcLiveData, - Message.RecipientType.CC, - binding?.progressBarCc - ) { - isUpdateCcCompleted = it - } - - handleUpdatingRecipients( - recipientsViewModel.recipientsBccLiveData, - Message.RecipientType.BCC, - binding?.progressBarBcc - ) { - isUpdateBccCompleted = it - } - } - - private fun handleUpdatingRecipients( - liveData: LiveData>>, - recipientType: Message.RecipientType, - progressBar: ProgressBar?, - updateState: (state: Boolean) -> Unit - ) { - liveData.observe(viewLifecycleOwner) { - when (it.status) { - Result.Status.LOADING -> { - updateState.invoke(false) - countingIdlingResource?.incrementSafely() - progressBar?.visible() - } - - Result.Status.SUCCESS -> { - updateState.invoke(true) - progressBar?.invisible() - it.data?.let { list -> - composeMsgViewModel.replaceRecipients(recipientType, list) - } - countingIdlingResource?.decrementSafely() - } - - Result.Status.ERROR, Result.Status.EXCEPTION -> { - updateState.invoke(true) - progressBar?.invisible() - showInfoSnackbar(view, it.exception?.message ?: getString(R.string.unknown_error)) - countingIdlingResource?.decrementSafely() - } - - Result.Status.NONE -> {} - } - } - } - /** * Add [AttachmentInfo] that was created from the given [Uri] * @@ -1509,7 +1257,7 @@ class CreateMessageFragment : BaseFragment(), composeMsgViewModel.recipientsStateFlow.collect { recipients -> if (isPasswordProtectedFunctionalityEnabled()) { val hasRecipientsWithoutPgp = - recipients.any { recipient -> !recipient.recipientWithPubKeys.hasAtLeastOnePubKey() } + recipients.any { recipient -> !recipient.value.recipientWithPubKeys.hasAtLeastOnePubKey() } if (hasRecipientsWithoutPgp) { binding?.btnSetWebPortalPassword?.visible() } else { @@ -1522,21 +1270,26 @@ class CreateMessageFragment : BaseFragment(), lifecycleScope.launchWhenStarted { composeMsgViewModel.recipientsToStateFlow.collect { recipients -> - updateChips(binding?.editTextRecipientTo, recipients.map { it.recipientWithPubKeys }) + updateChipAdapter(Message.RecipientType.TO, recipients) + updateAutoCompleteAdapter(recipients) } } lifecycleScope.launchWhenStarted { composeMsgViewModel.recipientsCcStateFlow.collect { recipients -> - binding?.layoutCc?.visibleOrGone(recipients.isNotEmpty()) - updateChips(binding?.editTextRecipientCc, recipients.map { it.recipientWithPubKeys }) + binding?.chipLayoutCc?.visibleOrGone(recipients.isNotEmpty()) + binding?.imageButtonAdditionalRecipientsVisibility?.visibleOrGone(recipients.isEmpty()) + updateChipAdapter(Message.RecipientType.CC, recipients) + updateAutoCompleteAdapter(recipients) } } lifecycleScope.launchWhenStarted { composeMsgViewModel.recipientsBccStateFlow.collect { recipients -> - binding?.layoutBcc?.visibleOrGone(recipients.isNotEmpty()) - updateChips(binding?.editTextRecipientBcc, recipients.map { it.recipientWithPubKeys }) + binding?.chipLayoutBcc?.visibleOrGone(recipients.isNotEmpty()) + binding?.imageButtonAdditionalRecipientsVisibility?.visibleOrGone(recipients.isEmpty()) + updateChipAdapter(Message.RecipientType.BCC, recipients) + updateAutoCompleteAdapter(recipients) } } @@ -1573,6 +1326,28 @@ class CreateMessageFragment : BaseFragment(), } } + private fun updateAutoCompleteAdapter(recipients: Map) { + val emails = recipients.keys + toAutoCompleteResultRecyclerViewAdapter.submitList( + toAutoCompleteResultRecyclerViewAdapter.currentList.map { + it.copy(isAdded = it.recipientWithPubKeys.recipient.email in emails) + }) + } + + private fun updateChipAdapter( + recipientType: Message.RecipientType, + recipients: Map + ) { + when (recipientType) { + Message.RecipientType.TO -> toRecipientsChipRecyclerViewAdapter.submitList( + recipients, + args.serviceInfo?.isToFieldEditable ?: true + ) + Message.RecipientType.CC -> ccRecipientsChipRecyclerViewAdapter.submitList(recipients) + Message.RecipientType.BCC -> bccRecipientsChipRecyclerViewAdapter.submitList(recipients) + } + } + private fun initNonEncryptedHintView() { nonEncryptedHintView = layoutInflater.inflate(R.layout.under_toolbar_line_with_text, appBarLayout, false) @@ -1597,53 +1372,28 @@ class CreateMessageFragment : BaseFragment(), * @return true if all information is correct, false otherwise. */ private fun isDataCorrect(): Boolean { - binding?.editTextRecipientTo?.chipifyAllUnterminatedTokens() - binding?.editTextRecipientCc?.chipifyAllUnterminatedTokens() - binding?.editTextRecipientBcc?.chipifyAllUnterminatedTokens() if (fromAddressesAdapter?.isEnabled( binding?.spinnerFrom?.selectedItemPosition ?: Spinner.INVALID_POSITION ) == false ) { - showInfoSnackbar(binding?.editTextRecipientTo, getString(R.string.no_key_available)) + showInfoSnackbar(msgText = getString(R.string.no_key_available)) return false } - if (binding?.editTextRecipientTo?.text?.isEmpty() == true) { + + if (composeMsgViewModel.recipientsTo.isEmpty()) { showInfoSnackbar( - binding?.editTextRecipientTo, getString( - R.string.text_must_not_be_empty, - getString(R.string.prompt_recipients_to) - ) + msgText = getString(R.string.add_recipient_to_send_message) ) - binding?.editTextRecipientTo?.requestFocus() - return false - } - if (hasInvalidEmail( - binding?.editTextRecipientTo, - binding?.editTextRecipientCc, - binding?.editTextRecipientBcc - ) - ) { + toRecipientsChipRecyclerViewAdapter.requestFocus() return false } + if (composeMsgViewModel.msgEncryptionType === MessageEncryptionType.ENCRYPTED) { - if (binding?.editTextRecipientTo?.text?.isNotEmpty() == true - && composeMsgViewModel.recipientWithPubKeysTo.isEmpty() - ) { - fetchDetailsAboutRecipients(Message.RecipientType.TO) - return false - } - if (binding?.editTextRecipientCc?.text?.isNotEmpty() == true - && composeMsgViewModel.recipientWithPubKeysCc.isEmpty() - ) { - fetchDetailsAboutRecipients(Message.RecipientType.CC) - return false - } - if (binding?.editTextRecipientBcc?.text?.isNotEmpty() == true - && composeMsgViewModel.recipientWithPubKeysBcc.isEmpty() - ) { - fetchDetailsAboutRecipients(Message.RecipientType.BCC) + if (composeMsgViewModel.allRecipients.any { it.value.isUpdating }) { + toast(R.string.please_wait_while_information_about_recipients_will_be_updated) return false } + if (hasUnusableRecipient()) { return false } @@ -1712,10 +1462,24 @@ class CreateMessageFragment : BaseFragment(), account = accountViewModel.activeAccountLiveData.value?.email ?: "", subject = binding?.editTextEmailSubject?.text.toString(), msg = msg, - toRecipients = binding?.editTextRecipientTo?.chipValues?.map { InternetAddress(it) } - ?: emptyList(), - ccRecipients = binding?.editTextRecipientCc?.chipValues?.map { InternetAddress(it) }, - bccRecipients = binding?.editTextRecipientBcc?.chipValues?.map { InternetAddress(it) }, + toRecipients = composeMsgViewModel.recipientsTo.values.map { + InternetAddress( + it.recipientWithPubKeys.recipient.email, + it.recipientWithPubKeys.recipient.name + ) + }, + ccRecipients = composeMsgViewModel.recipientsCc.values.map { + InternetAddress( + it.recipientWithPubKeys.recipient.email, + it.recipientWithPubKeys.recipient.name + ) + }, + bccRecipients = composeMsgViewModel.recipientsBcc.values.map { + InternetAddress( + it.recipientWithPubKeys.recipient.email, + it.recipientWithPubKeys.recipient.name + ) + }, from = InternetAddress(binding?.editTextFrom?.text.toString()), atts = attachments, forwardedAtts = getForwardedAttachments(), @@ -1729,8 +1493,8 @@ class CreateMessageFragment : BaseFragment(), private fun usePasswordIfNeeded(): CharArray? { return if (isPasswordProtectedFunctionalityEnabled()) { - for (recipient in composeMsgViewModel.recipientWithPubKeys) { - val recipientWithPubKeys = recipient.recipientWithPubKeys + for (recipient in composeMsgViewModel.allRecipients) { + val recipientWithPubKeys = recipient.value.recipientWithPubKeys if (!recipientWithPubKeys.hasAtLeastOnePubKey()) { return composeMsgViewModel.webPortalPasswordStateFlow.value.toString().toCharArray() } @@ -1770,15 +1534,11 @@ class CreateMessageFragment : BaseFragment(), cachedRecipientWithoutPubKeys?.recipient ) - updateRecipients() - updateChips(binding?.editTextRecipientTo, - composeMsgViewModel.recipientWithPubKeysTo.map { it.recipientWithPubKeys }) - updateChips( - binding?.editTextRecipientCc, - composeMsgViewModel.recipientWithPubKeysCc.map { it.recipientWithPubKeys }) - updateChips( - binding?.editTextRecipientBcc, - composeMsgViewModel.recipientWithPubKeysBcc.map { it.recipientWithPubKeys }) + cachedRecipientWithoutPubKeys?.recipient?.email?.let { email -> + composeMsgViewModel.reCacheRecipient(Message.RecipientType.TO, email) + composeMsgViewModel.reCacheRecipient(Message.RecipientType.CC, email) + composeMsgViewModel.reCacheRecipient(Message.RecipientType.BCC, email) + } toast(R.string.key_successfully_copied, Toast.LENGTH_LONG) cachedRecipientWithoutPubKeys = null @@ -1796,7 +1556,11 @@ class CreateMessageFragment : BaseFragment(), if (recipientWithPubKeys?.hasAtLeastOnePubKey() == true) { toast(R.string.the_key_successfully_imported) - updateRecipients() + recipientWithPubKeys.recipient.email.let { email -> + composeMsgViewModel.reCacheRecipient(Message.RecipientType.TO, email) + composeMsgViewModel.reCacheRecipient(Message.RecipientType.CC, email) + composeMsgViewModel.reCacheRecipient(Message.RecipientType.BCC, email) + } } } } @@ -1840,20 +1604,17 @@ class CreateMessageFragment : BaseFragment(), NoPgpFoundDialogFragment.RESULT_CODE_REMOVE_CONTACT -> { if (recipientWithPubKeys != null) { - removeRecipientWithPubKey( - recipientWithPubKeys, - binding?.editTextRecipientTo, - Message.RecipientType.TO + composeMsgViewModel.removeRecipient( + Message.RecipientType.TO, + recipientWithPubKeys.recipient.email ) - removeRecipientWithPubKey( - recipientWithPubKeys, - binding?.editTextRecipientCc, - Message.RecipientType.CC + composeMsgViewModel.removeRecipient( + Message.RecipientType.CC, + recipientWithPubKeys.recipient.email ) - removeRecipientWithPubKey( - recipientWithPubKeys, - binding?.editTextRecipientBcc, - Message.RecipientType.BCC + composeMsgViewModel.removeRecipient( + Message.RecipientType.BCC, + recipientWithPubKeys.recipient.email ) } } @@ -1879,6 +1640,75 @@ class CreateMessageFragment : BaseFragment(), } } + private fun setupRecipientsAutoCompleteViewModel() { + lifecycleScope.launchWhenStarted { + recipientsAutoCompleteViewModel.autoCompleteResultStateFlow.collect { + when (it.status) { + Result.Status.LOADING -> { + countingIdlingResource?.incrementSafely() + } + + Result.Status.SUCCESS -> { + val autoCompleteResults = it.data + val results = (autoCompleteResults?.results ?: emptyList()) + val emails = when (autoCompleteResults?.recipientType) { + Message.RecipientType.TO -> composeMsgViewModel.recipientsTo.keys + Message.RecipientType.CC -> composeMsgViewModel.recipientsCc.keys + Message.RecipientType.BCC -> composeMsgViewModel.recipientsBcc.keys + else -> throw InvalidObjectException( + "unknown RecipientType: ${autoCompleteResults?.recipientType}" + ) + } + val pattern = it.data?.pattern?.lowercase() ?: "" + + val autoCompleteList = results.map { recipientWithPubKeys -> + AutoCompleteResultRecyclerViewAdapter.AutoCompleteItem( + recipientWithPubKeys.recipient.email in emails, + recipientWithPubKeys + ) + } + + val finalList = if (pattern.isEmpty()) { + autoCompleteList + } else { + val hasMatchingEmail = autoCompleteList.map { autoCompleteItem -> + autoCompleteItem.recipientWithPubKeys.recipient.email.lowercase() + }.toSet().contains(pattern.lowercase()) + + if (hasMatchingEmail) { + autoCompleteList + } else { + autoCompleteList.toMutableList().apply { + add( + AutoCompleteResultRecyclerViewAdapter.AutoCompleteItem( + isAdded = false, + recipientWithPubKeys = RecipientWithPubKeys( + RecipientEntity(email = pattern), emptyList() + ), + type = AutoCompleteResultRecyclerViewAdapter.ADD + ) + ) + } + } + } + + val adapter = when (autoCompleteResults?.recipientType) { + Message.RecipientType.TO -> toAutoCompleteResultRecyclerViewAdapter + Message.RecipientType.CC -> ccAutoCompleteResultRecyclerViewAdapter + Message.RecipientType.BCC -> bccAutoCompleteResultRecyclerViewAdapter + else -> throw InvalidObjectException( + "unknown RecipientType: ${autoCompleteResults?.recipientType}" + ) + } + adapter.submitList(finalList) + countingIdlingResource?.decrementSafely() + } + else -> {} + } + } + } + } + companion object { private val TAG = CreateMessageFragment::class.java.simpleName } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/AutoCompleteResultRecyclerViewAdapter.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/AutoCompleteResultRecyclerViewAdapter.kt new file mode 100644 index 0000000000..28ef9edc2e --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/AutoCompleteResultRecyclerViewAdapter.kt @@ -0,0 +1,169 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.ui.adapter + +import android.graphics.Typeface +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.IntDef +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.flowcrypt.email.R +import com.flowcrypt.email.database.entity.relation.RecipientWithPubKeys +import com.flowcrypt.email.databinding.RecipientAutoCompleteItemBinding +import com.flowcrypt.email.extensions.kotlin.isValidEmail +import com.flowcrypt.email.extensions.toast +import com.flowcrypt.email.extensions.visibleOrGone +import jakarta.mail.Message + +/** + * @author Denis Bondarenko + * Date: 7/14/22 + * Time: 11:13 AM + * E-mail: DenBond7@gmail.com + */ +class AutoCompleteResultRecyclerViewAdapter( + val recipientType: Message.RecipientType, + private val resultListener: OnResultListener +) : ListAdapter(DIFF_CALLBACK) { + + init { + setHasStableIds(true) + } + + override fun getItemViewType(position: Int): Int { + return getItem(position).type + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): AutoCompleteResultRecyclerViewAdapter.BaseViewHolder { + return when (viewType) { + ADD -> AddViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.recipient_auto_complete_item, parent, false) + ) + else -> ResultViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.recipient_auto_complete_item, parent, false) + ) + } + + } + + override fun getItemId(position: Int): Long { + return when (getItem(position).type) { + ADD -> Long.MAX_VALUE + else -> requireNotNull(getItem(position).recipientWithPubKeys.recipient.id) + } + } + + override fun onBindViewHolder( + holder: AutoCompleteResultRecyclerViewAdapter.BaseViewHolder, + position: Int + ) { + when (holder) { + is AddViewHolder -> holder.bind(getItem(position)) + is ResultViewHolder -> holder.bind(getItem(position)) + } + } + + abstract inner class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) + + inner class AddViewHolder(itemView: View) : BaseViewHolder(itemView) { + private val binding: RecipientAutoCompleteItemBinding = + RecipientAutoCompleteItemBinding.bind(itemView) + + fun bind(autoCompleteItem: AutoCompleteItem) { + val context = itemView.context + val typedText = autoCompleteItem.recipientWithPubKeys.recipient.email + itemView.setOnClickListener { + if (typedText.isValidEmail()) { + resultListener.onResultClick(recipientType, autoCompleteItem.recipientWithPubKeys) + submitList(null) + } else { + context.toast(context.getString(R.string.type_valid_email_or_select_from_dropdown)) + } + } + + binding.imageViewPgp.setImageResource(R.drawable.ic_outline_add_circle_outline_32) + binding.textViewEmail.typeface = Typeface.DEFAULT_BOLD + binding.textViewEmail.text = autoCompleteItem.recipientWithPubKeys.recipient.email + binding.textViewName.typeface = Typeface.DEFAULT + binding.textViewName.text = context.getString(R.string.add_recipient) + } + } + + inner class ResultViewHolder(itemView: View) : BaseViewHolder(itemView) { + private val binding: RecipientAutoCompleteItemBinding = + RecipientAutoCompleteItemBinding.bind(itemView) + + fun bind(autoCompleteItem: AutoCompleteItem) { + val recipientWithPubKeys = autoCompleteItem.recipientWithPubKeys + itemView.setOnClickListener { + if (autoCompleteItem.isAdded) { + itemView.context.toast(itemView.context.getString(R.string.already_added)) + } else { + resultListener.onResultClick(recipientType, recipientWithPubKeys) + submitList(null) + } + } + + binding.textViewEmail.text = recipientWithPubKeys.recipient.email + binding.textViewName.text = recipientWithPubKeys.recipient.name + binding.textViewName.visibleOrGone(recipientWithPubKeys.recipient.name?.isNotEmpty() == true) + + binding.imageViewPgp.setColorFilter( + ContextCompat.getColor( + itemView.context, + if (recipientWithPubKeys.hasUsablePubKey()) R.color.colorPrimary else R.color.gray + ), android.graphics.PorterDuff.Mode.SRC_IN + ) + + binding.textViewUsed.visibleOrGone(autoCompleteItem.isAdded) + } + } + + interface OnResultListener { + fun onResultClick( + recipientType: Message.RecipientType, + recipientWithPubKeys: RecipientWithPubKeys + ) + } + + data class AutoCompleteItem( + val isAdded: Boolean, + val recipientWithPubKeys: RecipientWithPubKeys, + val type: Int = ITEM + ) + + companion object { + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(old: AutoCompleteItem, new: AutoCompleteItem): Boolean { + return old.recipientWithPubKeys.recipient.id == new.recipientWithPubKeys.recipient.id + } + + override fun areContentsTheSame( + old: AutoCompleteItem, + new: AutoCompleteItem + ): Boolean { + return old == new + } + } + + @IntDef(ADD, ITEM) + @Retention(AnnotationRetention.SOURCE) + annotation class Type + + const val ADD = 0 + const val ITEM = 1 + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/RecipientAdapter.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/RecipientAdapter.kt deleted file mode 100644 index b4ad0ece42..0000000000 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/RecipientAdapter.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 - */ - -package com.flowcrypt.email.ui.adapter - -import android.content.Context -import android.database.Cursor -import android.text.TextUtils -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.CursorAdapter -import android.widget.TextView -import com.flowcrypt.email.R -import com.flowcrypt.email.database.entity.relation.RecipientWithPubKeys -import com.hootsuite.nachos.NachoTextView - -/** - * This class describe a logic of create and show [RecipientWithPubKeys] objects in the - * [NachoTextView]. - * - * @author DenBond7 - * Date: 17.05.2017 - * Time: 17:44 - * E-mail: DenBond7@gmail.com - */ -class RecipientAdapter( - context: Context, - c: Cursor?, - autoRequery: Boolean -) : CursorAdapter(context, c, autoRequery) { - - override fun newView(context: Context, cursor: Cursor, parent: ViewGroup): View { - return LayoutInflater.from(context).inflate(R.layout.pgp_contact_item, parent, false) - } - - override fun convertToString(cursor: Cursor): CharSequence { - return getStringValue("email", cursor) - } - - override fun bindView(view: View, context: Context, cursor: Cursor) { - val textViewName = view.findViewById(R.id.textViewName) - val textViewEmail = view.findViewById(R.id.textViewEmail) - val textViewOnlyEmail = view.findViewById(R.id.textViewOnlyEmail) - - val name = getStringValue("name", cursor) - val email = getStringValue("email", cursor) - - if (TextUtils.isEmpty(name)) { - textViewEmail.text = null - textViewName.text = null - textViewOnlyEmail.text = email - } else { - textViewEmail.text = email - textViewName.text = name - textViewOnlyEmail.text = null - } - } - - private fun getStringValue(columnName: String, cursor: Cursor): String { - val columnIndex = cursor.getColumnIndex(columnName) - return if (columnIndex != -1) { - cursor.getString(columnIndex) ?: "" - } else { - "" - } - } -} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/RecipientChipRecyclerViewAdapter.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/RecipientChipRecyclerViewAdapter.kt new file mode 100644 index 0000000000..df62c17f53 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/RecipientChipRecyclerViewAdapter.kt @@ -0,0 +1,311 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.ui.adapter + +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.annotation.IntDef +import androidx.core.graphics.BlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat +import androidx.core.widget.addTextChangedListener +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.CircularProgressDrawable +import com.flowcrypt.email.R +import com.flowcrypt.email.database.entity.relation.RecipientWithPubKeys +import com.flowcrypt.email.databinding.ChipMoreItemBinding +import com.flowcrypt.email.databinding.ChipRecipientItemBinding +import com.flowcrypt.email.databinding.ComposeAddRecipientItemBinding +import com.flowcrypt.email.extensions.kotlin.isValidEmail +import com.flowcrypt.email.extensions.toast +import com.flowcrypt.email.util.UIUtil +import com.google.android.material.chip.Chip +import com.google.android.material.color.MaterialColors +import jakarta.mail.Message + + +/** + * @author Denis Bondarenko + * Date: 7/7/22 + * Time: 5:35 PM + * E-mail: DenBond7@gmail.com + */ +class RecipientChipRecyclerViewAdapter( + val recipientType: Message.RecipientType, + private val onChipsListener: OnChipsListener +) : ListAdapter(DIFF_CALLBACK) { + private var addViewHolder: AddViewHolder? = null + + var resetTypedText = false + set(value) { + field = value + if (value) { + addViewHolder?.binding?.editTextEmailAddress?.text = null + } + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): RecipientChipRecyclerViewAdapter.BaseViewHolder { + return when (viewType) { + ADD -> { + addViewHolder = AddViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.compose_add_recipient_item, parent, false) + ) + requireNotNull(addViewHolder) + } + + MORE -> MoreViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.chip_more_item, parent, false) + ) + + else -> ChipViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.chip_recipient_item, parent, false) + ) + } + } + + override fun onBindViewHolder( + holder: RecipientChipRecyclerViewAdapter.BaseViewHolder, + position: Int + ) { + when (holder) { + is AddViewHolder -> holder.bind() + is ChipViewHolder -> holder.bind(getItem(position).itemData as RecipientInfo) + is MoreViewHolder -> holder.bind(getItem(position).itemData as ItemData.More) + } + } + + override fun getItemViewType(position: Int): Int { + return getItem(position).type + } + + fun submitList(recipients: Map, isModifyingEnabled: Boolean = true) { + val filteredList = recipients.values + .take(if (hasInputFocus()) recipients.size else MAX_VISIBLE_ITEMS_COUNT) + val hasUpdatingInHiddenItems = (recipients.values - filteredList.toSet()).any { it.isUpdating } + val recipientInfoList = filteredList + .map { Item(CHIP, it.copy(isModifyingEnabled = isModifyingEnabled)) } + val finalList = recipientInfoList.toMutableList().apply { + if (recipients.size > MAX_VISIBLE_ITEMS_COUNT && !hasInputFocus()) { + add( + Item( + MORE, + ItemData.More( + value = recipients.size - filteredList.size, + hasUpdatingInHiddenItems = hasUpdatingInHiddenItems + ) + ) + ) + } + if (isModifyingEnabled) { + add(Item(ADD, ItemData.ADD)) + } + } + submitList(finalList) + } + + fun requestFocus() { + addViewHolder?.binding?.editTextEmailAddress?.requestFocus() + } + + private fun hasInputFocus(): Boolean { + return addViewHolder?.binding?.editTextEmailAddress?.hasFocus() == true + } + + abstract inner class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + fun prepareProgressDrawable(): Drawable { + return CircularProgressDrawable(itemView.context).apply { + setStyle(CircularProgressDrawable.DEFAULT) + colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat( + UIUtil.getColor(itemView.context, R.color.colorPrimary), BlendModeCompat.SRC_IN + ) + start() + } + } + } + + inner class AddViewHolder(itemView: View) : BaseViewHolder(itemView) { + val binding = ComposeAddRecipientItemBinding.bind(itemView) + + fun bind() { + binding.editTextEmailAddress.addTextChangedListener { editable -> + editable?.let { onChipsListener.onEmailAddressTyped(recipientType, it) } + } + + binding.editTextEmailAddress.setOnFocusChangeListener { _, hasFocus -> + onChipsListener.onAddFieldFocusChanged(recipientType, hasFocus) + if (!hasFocus) { + binding.editTextEmailAddress.text = null + onChipsListener.onEmailAddressTyped(recipientType, "") + } + } + + binding.editTextEmailAddress.setOnEditorActionListener { v, actionId, _ -> + return@setOnEditorActionListener when (actionId) { + EditorInfo.IME_ACTION_DONE, EditorInfo.IME_ACTION_NEXT -> { + if (v.text.toString().isValidEmail()) { + onChipsListener.onEmailAddressAdded(recipientType, v.text) + v.text = null + false + } else { + v.context.toast(v.context.getString(R.string.type_valid_email_or_select_from_dropdown)) + true + } + } + else -> false + } + } + } + } + + inner class ChipViewHolder(itemView: View) : BaseViewHolder(itemView) { + val binding = ChipRecipientItemBinding.bind(itemView) + fun bind(recipientInfo: RecipientInfo) { + val chip = binding.chip + chip.text = recipientInfo.recipientWithPubKeys.recipient.name + ?: recipientInfo.recipientWithPubKeys.recipient.email + + updateChipBackgroundColor(chip, recipientInfo) + updateChipTextColor(chip, recipientInfo) + updateChipIcon(chip, recipientInfo) + + chip.isCloseIconVisible = recipientInfo.isModifyingEnabled + chip.setOnCloseIconClickListener { + onChipsListener.onChipDeleted(recipientType, recipientInfo) + } + } + + private fun updateChipBackgroundColor(chip: Chip, recipientInfo: RecipientInfo) { + val recipientWithPubKeys = recipientInfo.recipientWithPubKeys + + val color = when { + recipientInfo.isUpdating -> { + MaterialColors.getColor(chip.context, R.attr.colorSurface, Color.WHITE) + } + + recipientWithPubKeys.hasAtLeastOnePubKey() -> { + val colorResId = when { + !recipientWithPubKeys.hasUsablePubKey() -> CHIP_COLOR_RES_ID_NO_USABLE_PUB_KEY + !recipientWithPubKeys.hasNotRevokedPubKey() -> CHIP_COLOR_RES_ID_HAS_PUB_KEY_BUT_REVOKED + !recipientWithPubKeys.hasNotExpiredPubKey() -> CHIP_COLOR_RES_ID_HAS_PUB_KEY_BUT_EXPIRED + else -> CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY + } + UIUtil.getColor(chip.context, colorResId) + } + + else -> { + UIUtil.getColor(chip.context, CHIP_COLOR_RES_ID_NO_PUB_KEY) + } + } + + chip.chipBackgroundColor = ColorStateList.valueOf(color) + } + + private fun updateChipTextColor(chip: Chip, recipientInfo: RecipientInfo) { + val color = when { + recipientInfo.isUpdating -> { + MaterialColors.getColor(chip.context, R.attr.colorOnSurface, Color.BLACK) + } + + else -> { + MaterialColors.getColor(chip.context, R.attr.colorOnSurfaceInverse, Color.WHITE) + } + } + + chip.setTextColor(color) + } + + private fun updateChipIcon(chip: Chip, recipientInfo: RecipientInfo) { + chip.chipIcon = if (recipientInfo.isUpdating) prepareProgressDrawable() else null + } + } + + inner class MoreViewHolder(itemView: View) : BaseViewHolder(itemView) { + val binding = ChipMoreItemBinding.bind(itemView) + + fun bind(more: ItemData.More) { + binding.chipMore.text = itemView.context.getString(R.string.more_recipients, more.value) + binding.chipMore.chipIcon = + if (more.hasUpdatingInHiddenItems) prepareProgressDrawable() else null + itemView.setOnClickListener { + addViewHolder?.binding?.editTextEmailAddress?.requestFocus() + } + } + } + + interface OnChipsListener { + fun onEmailAddressTyped(recipientType: Message.RecipientType, email: CharSequence) + fun onEmailAddressAdded(recipientType: Message.RecipientType, email: CharSequence) + fun onChipDeleted(recipientType: Message.RecipientType, recipientInfo: RecipientInfo) + fun onAddFieldFocusChanged(recipientType: Message.RecipientType, hasFocus: Boolean) + } + + data class RecipientInfo( + val recipientType: Message.RecipientType, + val recipientWithPubKeys: RecipientWithPubKeys, + val creationTime: Long = System.currentTimeMillis(), + var isUpdating: Boolean = true, + var isUpdateFailed: Boolean = false, + val isModifyingEnabled: Boolean = true + ) : ItemData { + override val uniqueId: Long = requireNotNull(recipientWithPubKeys.recipient.id) + } + + data class Item(@Type val type: Int, val itemData: ItemData) + + interface ItemData { + val uniqueId: Long + + data class More( + val value: Int, + val hasUpdatingInHiddenItems: Boolean, + override val uniqueId: Long = Long.MAX_VALUE + ) : ItemData + + companion object { + val ADD = object : ItemData { + override val uniqueId: Long + get() = Long.MIN_VALUE + } + } + } + + companion object { + const val CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY = R.color.colorPrimary + const val CHIP_COLOR_RES_ID_HAS_PUB_KEY_BUT_EXPIRED = R.color.orange + const val CHIP_COLOR_RES_ID_HAS_PUB_KEY_BUT_REVOKED = R.color.red + const val CHIP_COLOR_RES_ID_NO_PUB_KEY = R.color.gray + const val CHIP_COLOR_RES_ID_NO_USABLE_PUB_KEY = R.color.red + private const val MAX_VISIBLE_ITEMS_COUNT = 3 + + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(old: Item, new: Item) = + old.itemData.uniqueId == new.itemData.uniqueId + + override fun areContentsTheSame(old: Item, new: Item): Boolean = old == new + } + + @IntDef(CHIP, ADD, MORE) + @Retention(AnnotationRetention.SOURCE) + annotation class Type + + const val CHIP = 0 + const val ADD = 1 + const val MORE = 2 + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/CustomChipSpanChipCreator.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/CustomChipSpanChipCreator.kt deleted file mode 100644 index a426e3e862..0000000000 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/CustomChipSpanChipCreator.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 - */ - -package com.flowcrypt.email.ui.widget - -import android.content.Context -import android.content.res.ColorStateList -import android.database.Cursor -import com.flowcrypt.email.R -import com.flowcrypt.email.util.UIUtil -import com.hootsuite.nachos.ChipConfiguration -import com.hootsuite.nachos.chip.Chip -import com.hootsuite.nachos.chip.ChipCreator -import com.hootsuite.nachos.chip.ChipSpan -import com.hootsuite.nachos.chip.ChipSpanChipCreator - -/** - * This [ChipSpanChipCreator] responsible for displaying [Chip]. - * - * @author Denis Bondarenko - * Date: 31.07.2017 - * Time: 13:09 - * E-mail: DenBond7@gmail.com - */ -class CustomChipSpanChipCreator(context: Context) : ChipCreator { - private val bGColorHasUsablePubKey = - UIUtil.getColor(context, CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY) - private val bgColorHasPubKeyButExpired = - UIUtil.getColor(context, CHIP_COLOR_RES_ID_HAS_PUB_KEY_BUT_EXPIRED) - private val bgColorHasPubKeyButRevoked = - UIUtil.getColor(context, CHIP_COLOR_RES_ID_HAS_PUB_KEY_BUT_REVOKED) - private val bgColorNoPubKey = - UIUtil.getColor(context, CHIP_COLOR_RES_ID_NO_PUB_KEY) - private val bgColorNoUsablePubKey = - UIUtil.getColor(context, CHIP_COLOR_RES_ID_NO_USABLE_PUB_KEY) - private val textColorHasPubKey = UIUtil.getColor(context, android.R.color.white) - private val textColorNoPubKey = UIUtil.getColor(context, R.color.dark) - - override fun createChip(context: Context, text: CharSequence, data: Any?): PGPContactChipSpan { - return PGPContactChipSpan(context, text.toString().lowercase(), null, data) - } - - override fun createChip( - context: Context, - pgpContactChipSpan: PGPContactChipSpan - ): PGPContactChipSpan { - return PGPContactChipSpan(context, pgpContactChipSpan) - } - - override fun configureChip(span: PGPContactChipSpan, chipConfiguration: ChipConfiguration) { - val chipSpacing = chipConfiguration.chipHorizontalSpacing - if (chipSpacing != -1) { - span.setLeftMargin(chipSpacing / 2) - span.setRightMargin(chipSpacing / 2) - } - - val chipTextColor = chipConfiguration.chipTextColor - if (chipTextColor != -1) { - span.setTextColor(chipTextColor) - } - - val chipTextSize = chipConfiguration.chipTextSize - if (chipTextSize != -1) { - span.setTextSize(chipTextSize) - } - - val chipHeight = chipConfiguration.chipHeight - if (chipHeight != -1) { - span.setChipHeight(chipHeight) - } - - val chipVerticalSpacing = chipConfiguration.chipVerticalSpacing - if (chipVerticalSpacing != -1) { - span.setChipVerticalSpacing(chipVerticalSpacing) - } - - val maxAvailableWidth = chipConfiguration.maxAvailableWidth - if (maxAvailableWidth != -1) { - span.setMaxAvailableWidth(maxAvailableWidth) - } - - if (span.hasAtLeastOnePubKey != null) { - span.hasAtLeastOnePubKey?.let { updateChipSpanBackground(span) } - } else if (span.data != null && span.data is Cursor) { - val cursor = span.data as? Cursor ?: return - if (!cursor.isClosed) { - val columnIndex = cursor.getColumnIndex("has_pgp") - if (columnIndex != -1) { - val hasPgp = cursor.getInt(columnIndex) == 1 - span.hasAtLeastOnePubKey = hasPgp - updateChipSpanBackground(span) - } - } - } else { - val chipBackground = chipConfiguration.chipBackground - if (chipBackground != null) { - span.setBackgroundColor(chipBackground) - } - } - } - - /** - * Update the [ChipSpan] background. - * - * @param span The [ChipSpan] object. - */ - private fun updateChipSpanBackground(span: PGPContactChipSpan) { - if (span.hasAtLeastOnePubKey == true) { - when { - span.hasUsablePubKey == false -> { - span.setBackgroundColor(ColorStateList.valueOf(bgColorNoUsablePubKey)) - } - - span.hasNotRevokedPubKey == false -> { - span.setBackgroundColor(ColorStateList.valueOf(bgColorHasPubKeyButRevoked)) - } - - span.hasNotExpiredPubKey == false -> { - span.setBackgroundColor(ColorStateList.valueOf(bgColorHasPubKeyButExpired)) - } - - else -> { - span.setBackgroundColor(ColorStateList.valueOf(bGColorHasUsablePubKey)) - } - } - span.setTextColor(textColorHasPubKey) - } else { - span.setBackgroundColor(ColorStateList.valueOf(bgColorNoPubKey)) - span.setTextColor(textColorNoPubKey) - } - } - - companion object { - const val CHIP_COLOR_RES_ID_HAS_USABLE_PUB_KEY = R.color.colorPrimary - const val CHIP_COLOR_RES_ID_HAS_PUB_KEY_BUT_EXPIRED = R.color.orange - const val CHIP_COLOR_RES_ID_HAS_PUB_KEY_BUT_REVOKED = R.color.red - const val CHIP_COLOR_RES_ID_NO_PUB_KEY = R.color.aluminum - const val CHIP_COLOR_RES_ID_NO_USABLE_PUB_KEY = R.color.red - } -} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/PGPContactChipSpan.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/PGPContactChipSpan.kt deleted file mode 100644 index ae084266e1..0000000000 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/PGPContactChipSpan.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 - */ - -package com.flowcrypt.email.ui.widget - -import android.content.Context -import android.content.res.ColorStateList -import android.graphics.drawable.Drawable - -import com.hootsuite.nachos.chip.ChipSpan - -/** - * This class describes the representation of [ChipSpan] with PGP existing. - * - * @author Denis Bondarenko - * Date: 15.08.2017 - * Time: 16:28 - * E-mail: DenBond7@gmail.com - */ -class PGPContactChipSpan : ChipSpan { - var hasAtLeastOnePubKey: Boolean? = false - var hasNotExpiredPubKey: Boolean? = false - var hasUsablePubKey: Boolean? = false - var hasNotRevokedPubKey: Boolean? = false - - /** - * The last modified value that saved after [setBackgroundColor]. Can be null - */ - var chipBackgroundColor: ColorStateList? = null - - constructor(context: Context, text: CharSequence, icon: Drawable?, data: Any?) : super( - context, - text, - icon, - data - ) - - constructor(context: Context, pgpContactChipSpan: PGPContactChipSpan) : super( - context, - pgpContactChipSpan - ) { - this.hasAtLeastOnePubKey = pgpContactChipSpan.hasAtLeastOnePubKey - this.hasNotExpiredPubKey = pgpContactChipSpan.hasNotExpiredPubKey - this.hasUsablePubKey = pgpContactChipSpan.hasUsablePubKey - this.hasNotRevokedPubKey = pgpContactChipSpan.hasNotRevokedPubKey - } - - override fun setBackgroundColor(backgroundColor: ColorStateList?) { - super.setBackgroundColor(backgroundColor) - chipBackgroundColor = backgroundColor - } -} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/PgpContactsNachoTextView.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/PgpContactsNachoTextView.kt deleted file mode 100644 index d00d7de5c8..0000000000 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/PgpContactsNachoTextView.kt +++ /dev/null @@ -1,297 +0,0 @@ -/* - * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 - */ - -package com.flowcrypt.email.ui.widget - -import android.annotation.SuppressLint -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.text.Spannable -import android.text.SpannableString -import android.text.Spanned -import android.text.style.SuggestionSpan -import android.util.AttributeSet -import android.view.ActionMode -import android.view.GestureDetector -import android.view.HapticFeedbackConstants -import android.view.Menu -import android.view.MenuItem -import android.view.MotionEvent -import android.view.View -import android.widget.AdapterView -import com.flowcrypt.email.util.exception.ExceptionUtil -import com.hootsuite.nachos.NachoTextView -import com.hootsuite.nachos.chip.Chip -import java.util.* - -/** - * The custom realization of [NachoTextView]. - * - * @author DenBond7 - * Date: 19.05.2017 - * Time: 8:52 - * E-mail: DenBond7@gmail.com - */ -class PgpContactsNachoTextView(context: Context, attrs: AttributeSet) : - NachoTextView(context, attrs) { - private val gestureDetector: GestureDetector - private var listener: OnChipLongClickListener? = null - private val gestureListener: ChipLongClickOnGestureListener - - init { - this.gestureListener = ChipLongClickOnGestureListener() - this.gestureDetector = GestureDetector(getContext(), gestureListener) - customSelectionActionModeCallback = CustomActionModeCallback() - } - - /** - * This method prevents add a duplicate email from the dropdown to TextView. - */ - override fun onItemClick(adapterView: AdapterView<*>?, view: View?, position: Int, id: Long) { - val text = this.filter.convertResultToString(this.adapter.getItem(position)) - - if (!getText().toString().contains(text)) { - super.onItemClick(adapterView, view, position, id) - } - - } - - override fun toString(): String { - //Todo In this code I received a crash. Need to fix it. - try { - return super.toString() - } catch (e: Exception) { - e.printStackTrace() - ExceptionUtil.handleError(e) - } - - return text.toString() - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - gestureDetector.onTouchEvent(event) - return super.onTouchEvent(event) - } - - override fun onTextContextMenuItem(id: Int): Boolean { - val start = selectionStart - val end = selectionEnd - - when (id) { - android.R.id.cut -> { - setClipboardData( - ClipData.newPlainText( - null, - removeSuggestionSpans(getTextWithPlainTextSpans(start, end)) - ) - ) - text.delete(selectionStart, selectionEnd) - return true - } - - android.R.id.copy -> { - setClipboardData( - ClipData.newPlainText( - null, - removeSuggestionSpans(getTextWithPlainTextSpans(start, end)) - ) - ) - return true - } - - android.R.id.paste -> { - val stringBuilder = StringBuilder() - val clipboardManager = - context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = clipboardManager.primaryClip - if (clip != null) { - for (i in 0 until clip.itemCount) { - stringBuilder.append(clip.getItemAt(i).coerceToStyledText(context)) - } - } - - val emails = chipValues - if (emails.contains(stringBuilder.toString())) { - clipboardManager.setPrimaryClip(ClipData.newPlainText(null, " ")) - } - - return super.onTextContextMenuItem(id) - } - - else -> return super.onTextContextMenuItem(id) - } - } - - fun setListener(listener: OnChipLongClickListener) { - this.listener = listener - } - - private fun setClipboardData(clip: ClipData) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(clip) - } - - /** - * Get a formatted text of a selection. - * - * @param start The begin position of the selected text. - * @param end The end position of the selected text. - * @return A formatted text. - */ - private fun getTextWithPlainTextSpans(start: Int, end: Int): CharSequence { - val editable = text - - if (chipTokenizer != null) { - val stringBuilder = StringBuilder() - - val chips = listOf(*chipTokenizer!!.findAllChips(start, end, editable)) - for (i in chips.indices) { - val chip = chips[i] - stringBuilder.append(chip.text) - if (i != chips.size - 1) { - stringBuilder.append(CHIP_SEPARATOR_WHITESPACE) - } - } - - return stringBuilder.toString() - } - return editable.subSequence(start, end).toString() - } - - private fun removeSuggestionSpans(text: CharSequence): CharSequence { - var tempText = text - if (tempText is Spanned) { - val spannable: Spannable - if (tempText is Spannable) { - spannable = tempText - } else { - spannable = SpannableString(tempText) - tempText = spannable - } - - val spans = spannable.getSpans(0, tempText.length, SuggestionSpan::class.java) - for (span in spans) { - spannable.removeSpan(span) - } - } - return tempText - } - - interface OnChipLongClickListener { - /** - * Called when a chip in this TextView is long clicked. - * - * @param nachoTextView A current view - * @param chip the [Chip] that was clicked - * @param event the [MotionEvent] that caused the touch - */ - fun onChipLongClick(nachoTextView: NachoTextView, chip: Chip, event: MotionEvent) - } - - /** - * A custom realization of [ActionMode.Callback] which describes a logic of the text manipulation. - */ - private inner class CustomActionModeCallback : ActionMode.Callback { - - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - return true - } - - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - var isMenuModified = false - - val items = mutableListOf() - for (i in 0 until menu.size()) { - items.add(menu.getItem(i)) - } - - for (item in items) { - when (item.itemId) { - android.R.id.cut, android.R.id.copy -> { - } - - else -> { - menu.removeItem(item.itemId) - isMenuModified = true - } - } - } - - return isMenuModified - } - - override fun onActionItemClicked(mode: ActionMode, menuItem: MenuItem): Boolean { - when (menuItem.itemId) { - android.R.id.copy -> { - onTextContextMenuItem(android.R.id.copy) - mode.finish() - return true - } - } - return false - } - - override fun onDestroyActionMode(mode: ActionMode) { - - } - } - - private inner class ChipLongClickOnGestureListener : GestureDetector.SimpleOnGestureListener() { - override fun onLongPress(event: MotionEvent) { - super.onLongPress(event) - performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - - if (listener != null) { - val chip = findLongClickedChip(event) - - if (chip != null) { - listener!!.onChipLongClick(this@PgpContactsNachoTextView, chip, event) - } - } - } - - private fun findLongClickedChip(event: MotionEvent): Chip? { - if (chipTokenizer == null) { - return null - } - - val text = text - val offset = getOffsetForPosition(event.x, event.y) - val chips = allChips - for (chip in chips) { - val chipStart = chipTokenizer!!.findChipStart(chip, text) - val chipEnd = chipTokenizer!!.findChipEnd(chip, text) - if (offset in chipStart..chipEnd) { - val eventX = event.x - val startX = getPrimaryHorizontalForX(chipStart) - val endX = getPrimaryHorizontalForX(chipEnd - 1) - - val offsetLineNumber = getLineForOffset(offset) - val chipLineNumber = getLineForOffset(chipEnd - 1) - - if ((eventX in startX..endX) && offsetLineNumber == chipLineNumber) { - return chip - } - } - } - return null - } - - private fun getPrimaryHorizontalForX(offset: Int): Float { - val layout = layout - return layout.getPrimaryHorizontal(offset) - } - - private fun getLineForOffset(offset: Int): Int { - return layout.getLineForOffset(offset) - } - } - - companion object { - const val CHIP_SEPARATOR_WHITESPACE = ' ' - } -} diff --git a/FlowCrypt/src/main/res/drawable/ic_encrypted_badge_green_32.xml b/FlowCrypt/src/main/res/drawable/ic_encrypted_badge_green_32.xml new file mode 100644 index 0000000000..a93b7e79b3 --- /dev/null +++ b/FlowCrypt/src/main/res/drawable/ic_encrypted_badge_green_32.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/FlowCrypt/src/main/res/drawable/ic_outline_add_circle_outline_32.xml b/FlowCrypt/src/main/res/drawable/ic_outline_add_circle_outline_32.xml new file mode 100644 index 0000000000..21a6745a99 --- /dev/null +++ b/FlowCrypt/src/main/res/drawable/ic_outline_add_circle_outline_32.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/FlowCrypt/src/main/res/layout/chip_more_item.xml b/FlowCrypt/src/main/res/layout/chip_more_item.xml new file mode 100644 index 0000000000..4f44db2d76 --- /dev/null +++ b/FlowCrypt/src/main/res/layout/chip_more_item.xml @@ -0,0 +1,16 @@ + + + diff --git a/FlowCrypt/src/main/res/layout/chip_recipient_item.xml b/FlowCrypt/src/main/res/layout/chip_recipient_item.xml new file mode 100644 index 0000000000..3c31161a08 --- /dev/null +++ b/FlowCrypt/src/main/res/layout/chip_recipient_item.xml @@ -0,0 +1,17 @@ + + + diff --git a/FlowCrypt/src/main/res/layout/compose_add_recipient_item.xml b/FlowCrypt/src/main/res/layout/compose_add_recipient_item.xml new file mode 100644 index 0000000000..e12c11c83f --- /dev/null +++ b/FlowCrypt/src/main/res/layout/compose_add_recipient_item.xml @@ -0,0 +1,15 @@ + + + diff --git a/FlowCrypt/src/main/res/layout/fragment_create_message.xml b/FlowCrypt/src/main/res/layout/fragment_create_message.xml index c91c2c5d7a..096858624c 100644 --- a/FlowCrypt/src/main/res/layout/fragment_create_message.xml +++ b/FlowCrypt/src/main/res/layout/fragment_create_message.xml @@ -10,10 +10,7 @@ android:layout_height="match_parent" android:gravity="center_horizontal" android:orientation="vertical" - android:paddingLeft="@dimen/activity_horizontal_margin" - android:paddingTop="@dimen/activity_vertical_margin" - android:paddingRight="@dimen/activity_horizontal_margin" - android:paddingBottom="@dimen/activity_vertical_margin"> + android:padding="@dimen/default_margin_content"> - + android:layout_height="match_parent"> - - - - - - - - + android:layout_marginTop="@dimen/default_margin_content_small" + android:text="@string/to" + android:textColor="@color/nobel" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + + + - - - - - - - - + android:background="?android:attr/selectableItemBackgroundBorderless" + android:contentDescription="@string/show_cc_bcc" + android:padding="@dimen/default_margin_content_small" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="@+id/textViewTo" + app:srcCompat="@mipmap/ic_arrow_drop_down_grey" /> - + + + + android:layout_height="match_parent"> - - - - - - - - - - - + + + + + + + + + + + + android:layout_height="wrap_content"> - - - - - - - - - + android:layout_marginTop="@dimen/default_margin_content_small" + android:text="@string/bcc" + android:textColor="@color/nobel" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + + + + + + - - - - - - - - - diff --git a/FlowCrypt/src/main/res/layout/recipient_auto_complete_item.xml b/FlowCrypt/src/main/res/layout/recipient_auto_complete_item.xml new file mode 100644 index 0000000000..64d02aa2a9 --- /dev/null +++ b/FlowCrypt/src/main/res/layout/recipient_auto_complete_item.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + diff --git a/FlowCrypt/src/main/res/values-ru/strings.xml b/FlowCrypt/src/main/res/values-ru/strings.xml index c4b511ea0c..fb3ce2bde9 100644 --- a/FlowCrypt/src/main/res/values-ru/strings.xml +++ b/FlowCrypt/src/main/res/values-ru/strings.xml @@ -3,7 +3,6 @@ Тема Отправить Ошибка: неверный Email адрес - Ошибка: неверный Email \"%1$s\" Ошибка: нет подключения к Интернету Неизвестная ошибка Безопасность @@ -76,6 +75,8 @@ Новое сообщение Все письма Кому + Копия + Скрытая копия Добавить снимок экрана приложения Нажмите, чтобы просмотреть и отредактировать Конфиденциальность @@ -109,9 +110,6 @@ Выйти Сообщить об ошибке Написать безопасное сообщение - Добавить получателей (Копия) - Добавить получателей (Скрытая копия) - Добавить получателей (Кому) Приложить открытый ключ Переключиться на обычное сообщение Ошибка: \"%1$s\" не может быть пустым! @@ -221,7 +219,6 @@ Времено запомнить ключевую фразу Сохранить ключевую фразу и другие… - Копия Ответить Соединение потеряно Не удалось войти, пожалуйста, проверьте имя Вашей учетной записи и пароль в настройках сервера. @@ -482,4 +479,10 @@ Источник содержит более одного закрытого ключа Пожалуйста, введите Вашу ключевую фразу, чтобы поддерживать Ваши ключи в актуальном состоянии Вы уже имеете отозванную версию этого открытого ключа. Дальнейшие обновления запрещены. Пожалуйста, запросите другой открытый ключ у этого получателя. + Добавить получателя + Пожалуйста, введите корректный Email адрес или выберите из выпадающего списка + Добавлен + Уже добавлен + +%1$d ещё + Пожалуйста, добавьте хотя бы одного получателя в поле \"Кому\" для отправки этого сообщения diff --git a/FlowCrypt/src/main/res/values-uk/strings.xml b/FlowCrypt/src/main/res/values-uk/strings.xml index 386a147587..ff920e295a 100644 --- a/FlowCrypt/src/main/res/values-uk/strings.xml +++ b/FlowCrypt/src/main/res/values-uk/strings.xml @@ -4,7 +4,6 @@ Тема Відправити Помилка: невірна Email адреса - Помилка: невірний Email \"%1$s\" Помилка: намає підключення до Інтернету Невідома помилка Безпека @@ -77,6 +76,8 @@ Нове повідомлення Всі листи Кому + Копія + Прихована копія Додати знімок екрана застосунку Натисніть, щоб подивитися та відредагувати Конфіденційність @@ -110,9 +111,6 @@ Вийти Повідомити про помилку Написати безпечне повідомлення - Додати отримувачів (Копія) - Додати отримувачів (Прихована копія) - Додати отримувачів (Кому) Додати відкритий ключ Переключитись на звичайне повідомлення Помилка: \"%1$s\" не може бути порожнім! @@ -222,7 +220,6 @@ Тимчасово запам\'ятати ключову фразу Зберегти ключову фразу та інші… - Копія Відповісти З\'єдання втрачено Не вдалося увійти, будь ласка, перевірте ім\'я Вашого облікового запису та пароль у налаштуваннях сервера. @@ -483,4 +480,10 @@ Джерело містить більш ніж один закритий ключ Будь ласка, введіть Вашу ключову фразу, щоб підтримувати Ваші ключі в актуальному стані Ви вже маєте відкликану версію цього відкритого ключа. Подальші оновлення заборонені. Будь ласка, запросіть інший відкритий ключ у цього отримувача. + +%1$d ще + Додати отримувача + Будь ласка, введіть коректну Email адресу або виберіть зі списку + Додано + Вже додано + Щоб надіслати це повідомлення, додайте принаймні одного одержувача в поле \"Кому\" diff --git a/FlowCrypt/src/main/res/values/dimens.xml b/FlowCrypt/src/main/res/values/dimens.xml index 74ea3d75dc..9ee39e5db0 100644 --- a/FlowCrypt/src/main/res/values/dimens.xml +++ b/FlowCrypt/src/main/res/values/dimens.xml @@ -5,8 +5,6 @@ - 16dp - 16dp 16dp 24dp 8dp @@ -50,7 +48,6 @@ 60dp 32dp 24dp - 16dp 96dp 32dp 48dp @@ -68,7 +65,6 @@ 72dp 56dp 32dp - 48dp 150dp 100dp 200dp diff --git a/FlowCrypt/src/main/res/values/strings.xml b/FlowCrypt/src/main/res/values/strings.xml index d85c2cac80..597093eecd 100644 --- a/FlowCrypt/src/main/res/values/strings.xml +++ b/FlowCrypt/src/main/res/values/strings.xml @@ -8,16 +8,12 @@ human@flowcrypt.com Compose - Add recipients (To) - Add recipients (Cc) - Add recipients (Bcc) Subject Compose secure message Compose standard message Error: \"%1$s\" must not be empty! Send Error: invalid email - Error: invalid email \"%1$s\" Error: no internet Unknown error Privacy @@ -463,6 +459,7 @@ Reply To To Cc + Bcc Date To %1$s and others… @@ -569,4 +566,10 @@ Source contains more than one private key Please enter pass phrase to keep your account keys up to date You already have a revoked version of this Public Key. Further updates are not allowed. Please request another Public Key from this person. + Add recipient + Please type a valid email address or choose from a dropdown list + Added + Already added + +%1$d more + Please add at least one recipient in \"To\" to send this message