diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/BitmapMatcher.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/BitmapMatcher.kt new file mode 100644 index 0000000000..d8acef8cf8 --- /dev/null +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/BitmapMatcher.kt @@ -0,0 +1,37 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.matchers + +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.view.View +import android.widget.ImageView +import org.hamcrest.Description +import org.hamcrest.TypeSafeMatcher + +/** + * @author Denis Bondarenko + * Date: 8/9/22 + * Time: 11:07 AM + * E-mail: DenBond7@gmail.com + */ +class BitmapMatcher(private val expectedBitmap: Bitmap) : + TypeSafeMatcher(View::class.java) { + + override fun describeTo(description: Description) { + description.appendText("with expected bitmap size: ") + description.appendValue(expectedBitmap.byteCount) + } + + override fun matchesSafely(target: View): Boolean { + if (target !is ImageView) { + return false + } + + val targetBitmap = (target.drawable as? BitmapDrawable)?.bitmap + return targetBitmap?.sameAs(expectedBitmap) == true + } +} 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 d0a7c05877..b181ef1054 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/CustomMatchers.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/matchers/CustomMatchers.kt @@ -6,6 +6,7 @@ package com.flowcrypt.email.matchers import android.content.Context +import android.graphics.Bitmap import android.view.View import androidx.annotation.ColorRes import androidx.annotation.DrawableRes @@ -29,17 +30,18 @@ import org.hamcrest.Matcher class CustomMatchers { companion object { - @JvmStatic fun withDrawable(resourceId: Int): Matcher { return DrawableMatcher(resourceId) } - @JvmStatic + fun withBitmap(bitmap: Bitmap): Matcher { + return BitmapMatcher(bitmap) + } + fun emptyDrawable(): Matcher { return DrawableMatcher(DrawableMatcher.EMPTY) } - @JvmStatic fun withToolBarText(textMatcher: String): Matcher { return ToolBarTitleMatcher.withText(textMatcher) } @@ -49,7 +51,6 @@ class CustomMatchers { * * @param option An input {@link SecurityType.Option}. */ - @JvmStatic fun withSecurityTypeOption(option: SecurityType.Option): Matcher { return SecurityTypeOptionMatcher(option) } @@ -60,7 +61,6 @@ class CustomMatchers { * @param color An input color value. * @return true if matched, otherwise false */ - @JvmStatic fun withAppBarLayoutBackgroundColor(color: Int): BoundedMatcher { return AppBarLayoutBackgroundColorMatcher(color) } @@ -68,7 +68,6 @@ class CustomMatchers { /** * Match is [androidx.recyclerview.widget.RecyclerView] empty. */ - @JvmStatic fun withEmptyRecyclerView(): BaseMatcher { return EmptyRecyclerViewMatcher() } @@ -76,7 +75,6 @@ class CustomMatchers { /** * Match is an items count of [RecyclerView] empty. */ - @JvmStatic fun withRecyclerViewItemCount(itemCount: Int): BaseMatcher { return RecyclerViewItemCountMatcher(itemCount) } diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/SendFeedbackHasAccountFlowTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/SendFeedbackHasAccountFlowTest.kt new file mode 100644 index 0000000000..8b40b0c66f --- /dev/null +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/SendFeedbackHasAccountFlowTest.kt @@ -0,0 +1,168 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.ui + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +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.matcher.ViewMatchers.isDisplayed +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 +import com.flowcrypt.email.R +import com.flowcrypt.email.TestConstants +import com.flowcrypt.email.api.retrofit.ApiHelper +import com.flowcrypt.email.api.retrofit.response.api.PostHelpFeedbackResponse +import com.flowcrypt.email.api.retrofit.response.base.ApiError +import com.flowcrypt.email.model.Screenshot +import com.flowcrypt.email.rules.AddAccountToDatabaseRule +import com.flowcrypt.email.rules.AddPrivateKeyToDatabaseRule +import com.flowcrypt.email.rules.ClearAppSettingsRule +import com.flowcrypt.email.rules.FlowCryptMockWebServerRule +import com.flowcrypt.email.rules.RetryRule +import com.flowcrypt.email.rules.ScreenshotTestRule +import com.flowcrypt.email.ui.activity.MainActivity +import com.flowcrypt.email.ui.activity.fragment.FeedbackFragmentArgs +import com.flowcrypt.email.ui.base.BaseFeedbackFragmentTest +import com.flowcrypt.email.util.TestGeneralUtil +import com.flowcrypt.email.util.exception.ApiException +import com.google.gson.Gson +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestName +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import java.net.HttpURLConnection +import java.util.UUID + +/** + * @author Denis Bondarenko + * Date: 8/9/22 + * Time: 11:29 AM + * E-mail: DenBond7@gmail.com + */ +@MediumTest +@RunWith(AndroidJUnit4::class) +class SendFeedbackHasAccountFlowTest : BaseFeedbackFragmentTest() { + override val activityScenarioRule = activityScenarioRule( + TestGeneralUtil.genIntentForNavigationComponent( + destinationId = R.id.feedbackFragment, + extras = FeedbackFragmentArgs( + screenshot = Screenshot(SCREENSHOT_BYTE_ARRAY) + ).toBundle() + ) + ) + + @get:Rule + val testNameRule = TestName() + + private val mockWebServerRule = FlowCryptMockWebServerRule(TestConstants.MOCK_WEB_SERVER_PORT, + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val gson = ApiHelper.getInstance(getTargetContext()).gson + + if (request.path?.startsWith("/help/feedback") == true) { + return handlePostFeedbackRequest(gson) + } + + return MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND) + } + }) + + @get:Rule + var ruleChain: TestRule = RuleChain + .outerRule(RetryRule.DEFAULT) + .around(ClearAppSettingsRule()) + .around(mockWebServerRule) + .around(AddAccountToDatabaseRule()) + .around(AddPrivateKeyToDatabaseRule()) + .around(activityScenarioRule) + .around(ScreenshotTestRule()) + + @Test + fun testHandleApiErrorWhenSendingFeedback() { + onView(withId(R.id.editTextUserMessage)) + .perform(typeText(UUID.randomUUID().toString())) + + onView(withId(R.id.menuActionSend)) + .check(matches(isDisplayed())) + .perform(click()) + + val exception = ApiException(API_ERROR) + val errorMsg = if (exception.message.isNullOrEmpty()) { + exception.javaClass.simpleName + } else exception.message + + val dialogText = getResString( + R.string.send_feedback_failed_hint, + getResString(R.string.support_email), + errorMsg ?: "" + ) + + onView(withText(dialogText)) + .check(matches(isDisplayed())) + } + + @Test + fun testSendingFeedbackSuccess() { + onView(withId(R.id.editTextUserMessage)) + .perform(typeText(UUID.randomUUID().toString())) + + onView(withId(R.id.menuActionSend)) + .check(matches(isDisplayed())) + .perform(click()) + + onView(withId(R.id.tVStatusMessage)) + .check(doesNotExist()) + } + + @Test + fun testNavigateToImageEditor() { + onView(withId(R.id.checkBoxScreenshot)) + .check(matches(isDisplayed())) + .perform(click()) + + onView(withId(R.id.imageButtonScreenshot)) + .check(matches(isDisplayed())) + .perform(click()) + + onView(withId(R.id.photoEditorView)) + .check(matches(isDisplayed())) + } + + private fun handlePostFeedbackRequest(gson: Gson): MockResponse { + return when (testNameRule.methodName) { + "testHandleApiErrorWhenSendingFeedback" -> { + MockResponse() + .setResponseCode(HttpURLConnection.HTTP_BAD_REQUEST) + .setBody(gson.toJson(PostHelpFeedbackResponse(apiError = API_ERROR))) + } + + "testSendingFeedbackSuccess" -> { + MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(gson.toJson(PostHelpFeedbackResponse(isSent = true, text = "text"))) + } + + else -> MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND) + } + } + + companion object { + private val API_ERROR = ApiError( + code = HttpURLConnection.HTTP_BAD_REQUEST, + msg = "Wrong request received" + ) + } +} diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/base/BaseFeedbackFragmentTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/base/BaseFeedbackFragmentTest.kt new file mode 100644 index 0000000000..70eb9a16c9 --- /dev/null +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/base/BaseFeedbackFragmentTest.kt @@ -0,0 +1,28 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.ui.base + +import android.util.Base64 +import com.flowcrypt.email.base.BaseTest + +/** + * @author Denis Bondarenko + * Date: 8/9/22 + * Time: 11:24 AM + * E-mail: DenBond7@gmail.com + */ +abstract class BaseFeedbackFragmentTest : BaseTest() { + companion object { + private const val SCREENSHOT_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAQAAABKfvVzAAAA8klEQVQ" + + "4y8WUsQrCMBCGv076BFLppiK02t2u0ocSEdqO+hIKPoegi7g7F0HxBdTZOBjSpNbYzdz23/+Ru+QS+McaspQR1" + + "gNmCBlJPWClgLXNNmZBAIw4K+BCBPjMicv2BjkCwZGnsgsET44IBCeaJjAxbFUx0+0uNyO5JyVlb2gPvALocNF" + + "SGQ4ADpmmXunpe7TYyMRB2t/IQapb3HLbfZlKDTWVqv95rL4VGJTtLltrSTuzpB7XGk13C8Dj8fNY77SrB+5bT" + + "M0empyso5HTKLcdM8cHIu0Sz4yAgAVj29yuFbCq9x6S6oH7vkL1RIf/+CFeP17HNVfX5IMAAAAASUVORK5CYII=" + + val SCREENSHOT_BYTE_ARRAY: ByteArray = Base64.decode(SCREENSHOT_BASE64, Base64.DEFAULT) + } +} diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/FeedbackFragmentHasAccountInIsolationTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/FeedbackFragmentHasAccountInIsolationTest.kt new file mode 100644 index 0000000000..12d8c761cf --- /dev/null +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/FeedbackFragmentHasAccountInIsolationTest.kt @@ -0,0 +1,61 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.ui.fragment.isolation.incontainer + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.flowcrypt.email.R +import com.flowcrypt.email.model.Screenshot +import com.flowcrypt.email.rules.AddAccountToDatabaseRule +import com.flowcrypt.email.rules.ClearAppSettingsRule +import com.flowcrypt.email.rules.RetryRule +import com.flowcrypt.email.rules.ScreenshotTestRule +import com.flowcrypt.email.ui.activity.fragment.FeedbackFragment +import com.flowcrypt.email.ui.activity.fragment.FeedbackFragmentArgs +import com.flowcrypt.email.ui.base.BaseFeedbackFragmentTest +import org.hamcrest.Matchers.not +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +/** + * @author Denis Bondarenko + * Date: 8/9/22 + * Time: 11:20 AM + * E-mail: DenBond7@gmail.com + */ +@MediumTest +@RunWith(AndroidJUnit4::class) +class FeedbackFragmentHasAccountInIsolationTest : BaseFeedbackFragmentTest() { + @get:Rule + var ruleChain: TestRule = RuleChain + .outerRule(RetryRule.DEFAULT) + .around(ClearAppSettingsRule()) + .around(AddAccountToDatabaseRule()) + .around(ScreenshotTestRule()) + + @Before + fun launchFragmentInContainerWithPredefinedArgs() { + launchFragmentInContainer( + fragmentArgs = FeedbackFragmentArgs( + screenshot = Screenshot(SCREENSHOT_BYTE_ARRAY) + ).toBundle() + ) + } + + @Test + fun testUserEmailVisibility() { + onView(withId(R.id.editTextUserEmail)) + .check(matches(not(isDisplayed()))) + } +} diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/FeedbackFragmentNoAccountsInIsolationTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/FeedbackFragmentNoAccountsInIsolationTest.kt new file mode 100644 index 0000000000..76c14b0753 --- /dev/null +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/FeedbackFragmentNoAccountsInIsolationTest.kt @@ -0,0 +1,80 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.ui.fragment.isolation.incontainer + +import android.graphics.BitmapFactory +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.flowcrypt.email.R +import com.flowcrypt.email.matchers.CustomMatchers.Companion.withBitmap +import com.flowcrypt.email.model.Screenshot +import com.flowcrypt.email.rules.ClearAppSettingsRule +import com.flowcrypt.email.rules.RetryRule +import com.flowcrypt.email.rules.ScreenshotTestRule +import com.flowcrypt.email.ui.activity.fragment.FeedbackFragment +import com.flowcrypt.email.ui.activity.fragment.FeedbackFragmentArgs +import com.flowcrypt.email.ui.base.BaseFeedbackFragmentTest +import org.hamcrest.Matchers.not +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +/** + * @author Denis Bondarenko + * Date: 8/9/22 + * Time: 10:32 AM + * E-mail: DenBond7@gmail.com + */ +@MediumTest +@RunWith(AndroidJUnit4::class) +class FeedbackFragmentNoAccountsInIsolationTest : BaseFeedbackFragmentTest() { + @get:Rule + var ruleChain: TestRule = RuleChain + .outerRule(RetryRule.DEFAULT) + .around(ClearAppSettingsRule()) + .around(ScreenshotTestRule()) + + @Before + fun launchFragmentInContainerWithPredefinedArgs() { + launchFragmentInContainer( + fragmentArgs = FeedbackFragmentArgs( + screenshot = Screenshot(SCREENSHOT_BYTE_ARRAY) + ).toBundle() + ) + } + + @Test + fun testUserEmailVisibility() { + onView(withId(R.id.editTextUserEmail)) + .check(matches(isDisplayed())) + } + + @Test + fun testIncludeAppScreenshot() { + onView(withId(R.id.imageButtonScreenshot)) + .check(matches(not(isDisplayed()))) + onView(withId(R.id.checkBoxScreenshot)) + .check(matches(isDisplayed())) + .perform(click()) + onView(withId(R.id.imageButtonScreenshot)) + .check(matches(isDisplayed())) + .check( + matches( + withBitmap( + BitmapFactory.decodeByteArray(SCREENSHOT_BYTE_ARRAY, 0, SCREENSHOT_BYTE_ARRAY.size) + ) + ) + ) + } +} diff --git a/FlowCrypt/src/main/AndroidManifest.xml b/FlowCrypt/src/main/AndroidManifest.xml index c91fcf1d18..25d2737cf5 100644 --- a/FlowCrypt/src/main/AndroidManifest.xml +++ b/FlowCrypt/src/main/AndroidManifest.xml @@ -165,11 +165,6 @@ android:exported="false" android:permission="android.permission.BIND_JOB_SERVICE" /> - - diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiRepository.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiRepository.kt index 63118b796c..61efd020e8 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiRepository.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiRepository.kt @@ -10,12 +10,14 @@ import com.flowcrypt.email.api.retrofit.base.BaseApiRepository import com.flowcrypt.email.api.retrofit.request.model.InitialLegacySubmitModel import com.flowcrypt.email.api.retrofit.request.model.LoginModel import com.flowcrypt.email.api.retrofit.request.model.MessageUploadRequest +import com.flowcrypt.email.api.retrofit.request.model.PostHelpFeedbackModel import com.flowcrypt.email.api.retrofit.request.model.TestWelcomeModel import com.flowcrypt.email.api.retrofit.response.api.EkmPrivateKeysResponse import com.flowcrypt.email.api.retrofit.response.api.FesServerResponse import com.flowcrypt.email.api.retrofit.response.api.LoginResponse import com.flowcrypt.email.api.retrofit.response.api.MessageReplyTokenResponse import com.flowcrypt.email.api.retrofit.response.api.MessageUploadResponse +import com.flowcrypt.email.api.retrofit.response.api.PostHelpFeedbackResponse import com.flowcrypt.email.api.retrofit.response.attester.InitialLegacySubmitResponse import com.flowcrypt.email.api.retrofit.response.attester.PubResponse import com.flowcrypt.email.api.retrofit.response.attester.TestWelcomeResponse @@ -167,4 +169,15 @@ interface ApiRepository : BaseApiRepository { messageUploadRequest: MessageUploadRequest, msg: String ): Result + + /** + * Post a user feedback to our server + * + * @param context Interface to global information about an application environment. + * @param postHelpFeedbackModel an instance of [PostHelpFeedbackModel] + */ + suspend fun postHelpFeedback( + context: Context, + postHelpFeedbackModel: PostHelpFeedbackModel + ): Result } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiService.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiService.kt index 2dce1741a9..50f4b41f56 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiService.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiService.kt @@ -93,7 +93,7 @@ interface ApiService { * @return [<] */ @POST(BuildConfig.API_URL + "help/feedback") - fun postHelpFeedback(@Body body: PostHelpFeedbackModel): Call + suspend fun postHelpFeedback(@Body body: PostHelpFeedbackModel): Response /** * This method create a [Call] object for the API "https://flowcrypt.com/attester/pub" diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/FlowcryptApiRepository.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/FlowcryptApiRepository.kt index eb2a8c864d..f7ccfa789f 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/FlowcryptApiRepository.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/FlowcryptApiRepository.kt @@ -11,12 +11,14 @@ import com.flowcrypt.email.R import com.flowcrypt.email.api.retrofit.request.model.InitialLegacySubmitModel import com.flowcrypt.email.api.retrofit.request.model.LoginModel import com.flowcrypt.email.api.retrofit.request.model.MessageUploadRequest +import com.flowcrypt.email.api.retrofit.request.model.PostHelpFeedbackModel import com.flowcrypt.email.api.retrofit.request.model.TestWelcomeModel import com.flowcrypt.email.api.retrofit.response.api.EkmPrivateKeysResponse import com.flowcrypt.email.api.retrofit.response.api.FesServerResponse import com.flowcrypt.email.api.retrofit.response.api.LoginResponse import com.flowcrypt.email.api.retrofit.response.api.MessageReplyTokenResponse import com.flowcrypt.email.api.retrofit.response.api.MessageUploadResponse +import com.flowcrypt.email.api.retrofit.response.api.PostHelpFeedbackResponse import com.flowcrypt.email.api.retrofit.response.attester.InitialLegacySubmitResponse import com.flowcrypt.email.api.retrofit.response.attester.PubResponse import com.flowcrypt.email.api.retrofit.response.attester.TestWelcomeResponse @@ -287,4 +289,15 @@ class FlowcryptApiRepository : ApiRepository { ) } } + + override suspend fun postHelpFeedback( + context: Context, + postHelpFeedbackModel: PostHelpFeedbackModel + ): Result = withContext(Dispatchers.IO) { + val apiService = ApiHelper.getInstance(context).retrofit.create(ApiService::class.java) + getResult( + context = context, + expectedResultClass = PostHelpFeedbackResponse::class.java + ) { apiService.postHelpFeedback(postHelpFeedbackModel) } + } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/api/PostHelpFeedbackResponse.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/api/PostHelpFeedbackResponse.kt index c78f685687..7a4ca6ef11 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/api/PostHelpFeedbackResponse.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/api/PostHelpFeedbackResponse.kt @@ -35,9 +35,9 @@ import com.google.gson.annotations.SerializedName * E-mail: DenBond7@gmail.com */ data class PostHelpFeedbackResponse constructor( - @SerializedName("error") @Expose override val apiError: ApiError?, - @SerializedName("sent") @Expose val isSent: Boolean, - @Expose val text: String? + @SerializedName("error") @Expose override val apiError: ApiError? = null, + @SerializedName("sent") @Expose val isSent: Boolean? = null, + @Expose val text: String? = null ) : ApiResponse { constructor(source: Parcel) : this( source.readParcelable(ApiError::class.java.classLoader), @@ -52,7 +52,7 @@ data class PostHelpFeedbackResponse constructor( override fun writeToParcel(dest: Parcel, flags: Int) = with(dest) { writeParcelable(apiError, 0) - writeInt((if (isSent) 1 else 0)) + writeInt((if (isSent == true) 1 else 0)) writeString(text) } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/LifecycleOwnerExt.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/LifecycleOwnerExt.kt index ced1fb9f32..522da571ac 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/LifecycleOwnerExt.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/LifecycleOwnerExt.kt @@ -12,7 +12,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.navigation.NavController import androidx.navigation.NavDirections import com.flowcrypt.email.R -import com.flowcrypt.email.ui.activity.fragment.FeedbackFragment +import com.flowcrypt.email.model.Screenshot import com.flowcrypt.email.ui.activity.fragment.FeedbackFragmentArgs import com.flowcrypt.email.ui.activity.fragment.dialog.FixNeedPassphraseIssueDialogFragment import com.flowcrypt.email.ui.activity.fragment.dialog.FixNeedPassphraseIssueDialogFragmentArgs @@ -146,7 +146,7 @@ fun LifecycleOwner.showFeedbackFragment( val navDirections = object : NavDirections { override val actionId: Int = R.id.feedback_graph override val arguments: Bundle = FeedbackFragmentArgs( - screenshot = FeedbackFragment.Screenshot(it) + screenshot = Screenshot(it) ).toBundle() } navController?.navigate(navDirections) diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/LauncherViewModel.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/LauncherViewModel.kt index f7b93b92d2..31da13dc98 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/LauncherViewModel.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/LauncherViewModel.kt @@ -12,7 +12,6 @@ import com.flowcrypt.email.R import com.flowcrypt.email.database.entity.AccountEntity import com.flowcrypt.email.jetpack.workmanager.ForwardedAttachmentsDownloaderWorker import com.flowcrypt.email.jetpack.workmanager.MessagesSenderWorker -import com.flowcrypt.email.service.FeedbackJobIntentService import com.flowcrypt.email.util.CacheManager import com.flowcrypt.email.util.FileAndDirectoryUtils import kotlinx.coroutines.flow.MutableStateFlow @@ -41,7 +40,6 @@ class LauncherViewModel(application: Application) : AccountViewModel(application ) ForwardedAttachmentsDownloaderWorker.enqueue(application) MessagesSenderWorker.enqueue(application) - FeedbackJobIntentService.enqueueWork(application) FileAndDirectoryUtils.cleanDir(CacheManager.getCurrentMsgTempDir()) isInitLoadingCompletedMutableStateFlow.value = diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/SendFeedbackViewModel.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/SendFeedbackViewModel.kt new file mode 100644 index 0000000000..52e72116e7 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/SendFeedbackViewModel.kt @@ -0,0 +1,65 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.jetpack.viewmodel + +import android.app.Application +import android.content.Context +import android.util.Base64 +import androidx.lifecycle.viewModelScope +import com.flowcrypt.email.BuildConfig +import com.flowcrypt.email.R +import com.flowcrypt.email.api.retrofit.FlowcryptApiRepository +import com.flowcrypt.email.api.retrofit.request.model.PostHelpFeedbackModel +import com.flowcrypt.email.api.retrofit.response.api.PostHelpFeedbackResponse +import com.flowcrypt.email.api.retrofit.response.base.Result +import com.flowcrypt.email.database.entity.AccountEntity +import com.flowcrypt.email.model.Screenshot +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * @author Denis Bondarenko + * Date: 8/8/22 + * Time: 1:07 PM + * E-mail: DenBond7@gmail.com + */ +class SendFeedbackViewModel(application: Application) : BaseAndroidViewModel(application) { + private val repository = FlowcryptApiRepository() + private val postFeedbackMutableStateFlow: MutableStateFlow> = + MutableStateFlow(Result.none()) + val postFeedbackStateFlow: StateFlow> = + postFeedbackMutableStateFlow.asStateFlow() + + fun postFeedback( + account: AccountEntity, + feedbackMsg: String, + screenshot: Screenshot? = null + ) { + viewModelScope.launch { + val context: Context = getApplication() + postFeedbackMutableStateFlow.value = + Result.loading(progressMsg = context.getString(R.string.sending)) + val screenShotBase64 = + Base64.encodeToString(screenshot?.byteArray ?: byteArrayOf(), Base64.DEFAULT) + + try { + postFeedbackMutableStateFlow.value = repository.postHelpFeedback( + context = context, + PostHelpFeedbackModel( + email = account.email, + logs = "", + screenshot = screenShotBase64, + msg = "$feedbackMsg\n\nversion: Android ${BuildConfig.VERSION_NAME}" + ) + ) + } catch (e: Exception) { + postFeedbackMutableStateFlow.value = Result.exception(e) + } + } + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/jobscheduler/JobIdManager.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/jobscheduler/JobIdManager.kt index db32095df4..91d8d0d4ac 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/jobscheduler/JobIdManager.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/jobscheduler/JobIdManager.kt @@ -24,6 +24,5 @@ class JobIdManager { const val JOB_TYPE_ACTION_QUEUE = 2 const val JOB_TYPE_PREPARE_OUT_GOING_MESSAGE = 4 - const val JOB_TYPE_FEEDBACK = 5 } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/model/Screenshot.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/model/Screenshot.kt new file mode 100644 index 0000000000..dbc10a4967 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/model/Screenshot.kt @@ -0,0 +1,47 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.model + +import android.os.Parcel +import android.os.Parcelable + +/** + * @author Denis Bondarenko + * Date: 8/8/22 + * Time: 2:23 PM + * E-mail: DenBond7@gmail.com + */ +data class Screenshot(val byteArray: ByteArray) : Parcelable { + constructor(parcel: Parcel) : this(parcel.createByteArray() ?: byteArrayOf()) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Screenshot + + if (!byteArray.contentEquals(other.byteArray)) return false + + return true + } + + override fun hashCode(): Int { + return byteArray.contentHashCode() + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeByteArray(byteArray) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = Screenshot(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/service/FeedbackJobIntentService.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/service/FeedbackJobIntentService.kt deleted file mode 100644 index 5ddb5b0664..0000000000 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/service/FeedbackJobIntentService.kt +++ /dev/null @@ -1,170 +0,0 @@ -/* - * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 - */ - -package com.flowcrypt.email.service - -import android.content.Context -import android.content.Intent -import android.util.Base64 -import androidx.core.app.JobIntentService -import com.flowcrypt.email.BuildConfig -import com.flowcrypt.email.api.retrofit.ApiHelper -import com.flowcrypt.email.api.retrofit.ApiService -import com.flowcrypt.email.api.retrofit.request.model.PostHelpFeedbackModel -import com.flowcrypt.email.database.entity.AccountEntity -import com.flowcrypt.email.jobscheduler.JobIdManager -import com.flowcrypt.email.util.GeneralUtil -import com.flowcrypt.email.util.cache.DiskLruCache -import com.google.gson.GsonBuilder -import okhttp3.internal.io.FileSystem -import okio.buffer -import java.io.File -import java.io.InputStreamReader -import java.nio.charset.StandardCharsets -import java.util.UUID - -/** - * This service sends a user feedback to our API - * - * @author Denis Bondarenko - * Date: 9/3/19 - * Time: 9:09 AM - * E-mail: DenBond7@gmail.com - */ -class FeedbackJobIntentService : JobIntentService() { - private lateinit var diskLruCache: DiskLruCache - private val gson = GsonBuilder().create() - - override fun onCreate() { - super.onCreate() - initFeedbackCache(this) - } - - private fun initFeedbackCache(context: Context) { - diskLruCache = DiskLruCache( - FileSystem.SYSTEM, - File(context.cacheDir, CACHE_DIR_NAME), - CACHE_VERSION, - CACHE_SIZE - ) - } - - override fun onHandleWork(intent: Intent) { - addFeedbackFromIntentToCache(intent) - - if (GeneralUtil.isConnected(this)) { - sendCachedFeedback() - } - } - - private fun addFeedbackFromIntentToCache(intent: Intent) { - val account = intent.getParcelableExtra(EXTRA_KEY_ACCOUNT) - val feedbackMsg = intent.getStringExtra(EXTRA_KEY_FEEDBACK_MSG) - val screenShotBytes = intent.getByteArrayExtra(EXTRA_KEY_SCREENSHOT_BYTES) - val screenShotBase64 = Base64.encodeToString(screenShotBytes ?: byteArrayOf(), Base64.DEFAULT) - - feedbackMsg?.let { - addFeedbackToCache( - UUID.randomUUID().toString(), - FeedBackItem(account?.email, feedbackMsg, screenShotBase64) - ) - } - } - - private fun addFeedbackToCache(key: String, feedBackItem: FeedBackItem) { - val editor = diskLruCache.edit(key) ?: return - - val bufferedSink = editor.newSink().buffer() - bufferedSink.writeString(gson.toJson(feedBackItem), StandardCharsets.UTF_8) - bufferedSink.flush() - editor.commit() - bufferedSink.close() - } - - private fun sendCachedFeedback() { - val itemsIterator = diskLruCache.snapshots() - val apiService = ApiHelper.getInstance(this).retrofit.create(ApiService::class.java) - - while (itemsIterator.hasNext()) { - val item = itemsIterator.next() - val bufferedSource = item.getSource(0).buffer() - val inputStreamReader = InputStreamReader(bufferedSource.inputStream()) - val feedBackItem = gson.fromJson(inputStreamReader, FeedBackItem::class.java) - bufferedSource.close() - - val response = with(feedBackItem) { - apiService - .postHelpFeedback( - PostHelpFeedbackModel( - email = email ?: "", - logs = "", - screenshot = screenShot, - msg = "$feedbackMsg\n\nversion: Android ${BuildConfig.VERSION_NAME}" - ) - ) - .execute() - } - - if (response.isSuccessful) { - val body = response.body() - if (body?.isSent == true) { - diskLruCache.remove(item.key()) - } - } - } - } - - companion object { - private val EXTRA_KEY_ACCOUNT = - GeneralUtil.generateUniqueExtraKey("EXTRA_KEY_ACCOUNT", FeedbackJobIntentService::class.java) - private val EXTRA_KEY_FEEDBACK_MSG = - GeneralUtil.generateUniqueExtraKey( - "EXTRA_KEY_FEEDBACK_MSG", - FeedbackJobIntentService::class.java - ) - private val EXTRA_KEY_SCREENSHOT_BYTES = - GeneralUtil.generateUniqueExtraKey( - "EXTRA_KEY_SCREENSHOT_BYTES", - FeedbackJobIntentService::class.java - ) - - private const val CACHE_VERSION = 1 - private const val CACHE_SIZE: Long = 1024 * 1000 * 3 //3Mb - private const val CACHE_DIR_NAME = "feedback" - - /** - * Enqueue a new task for [FeedbackJobIntentService]. Set the feedback param to null to - * enqueue checking of non-sent feedbacks - * - * @param context Interface to global information about an application environment. - * @param account An active account. - * @param userComment A feedback which will be sent. - * @param screenShotBytes A screenshot bytes array. - */ - @JvmStatic - fun enqueueWork( - context: Context, account: AccountEntity? = null, userComment: String? = null, - screenShotBytes: ByteArray? = null - ) { - val intent = Intent(context, FeedbackJobIntentService::class.java) - intent.putExtra(EXTRA_KEY_ACCOUNT, account) - intent.putExtra(EXTRA_KEY_FEEDBACK_MSG, userComment) - intent.putExtra(EXTRA_KEY_SCREENSHOT_BYTES, screenShotBytes) - enqueueWork( - context, FeedbackJobIntentService::class.java, - JobIdManager.JOB_TYPE_FEEDBACK, intent - ) - } - } - - /** - * It's data class which describes info about a feedback - */ - data class FeedBackItem( - val email: String?, - val feedbackMsg: String, - val screenShot: String? - ) -} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/FeedbackFragment.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/FeedbackFragment.kt index deaff39d1d..f830a8ff3c 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/FeedbackFragment.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/FeedbackFragment.kt @@ -8,8 +8,6 @@ package com.flowcrypt.email.ui.activity.fragment import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Bundle -import android.os.Parcel -import android.os.Parcelable import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -29,9 +27,10 @@ import com.flowcrypt.email.extensions.gone import com.flowcrypt.email.extensions.navController import com.flowcrypt.email.extensions.toast import com.flowcrypt.email.extensions.visibleOrGone -import com.flowcrypt.email.service.FeedbackJobIntentService +import com.flowcrypt.email.model.Screenshot import com.flowcrypt.email.ui.activity.fragment.base.BaseFragment import com.flowcrypt.email.ui.activity.fragment.dialog.EditScreenshotDialogFragment +import com.flowcrypt.email.ui.activity.fragment.dialog.SendFeedbackDialogFragment import com.flowcrypt.email.util.GeneralUtil import com.flowcrypt.email.util.UIUtil @@ -61,6 +60,7 @@ class FeedbackFragment : BaseFragment() { super.onViewCreated(view, savedInstanceState) initViews() subscribeToEditScreenshot() + subscribeToSendFeedback() } override fun onSetupActionBarMenu(menuHost: MenuHost) { @@ -105,14 +105,13 @@ class FeedbackFragment : BaseFragment() { val nonNullAccount = account ?: AccountEntity(email = binding?.editTextUserEmail?.text.toString()) - FeedbackJobIntentService.enqueueWork( - context = requireContext(), - account = nonNullAccount, - userComment = binding?.editTextUserMessage?.text.toString(), - screenShotBytes = screenShotBytes + navController?.navigate( + FeedbackFragmentDirections.actionFeedbackFragmentToSendFeedbackDialogFragment( + nonNullAccount, + binding?.editTextUserMessage?.text.toString(), + screenShotBytes?.let { Screenshot(it) } + ) ) - toast(getString(R.string.thank_you_for_feedback)) - navController?.navigateUp() } return true } @@ -160,35 +159,13 @@ class FeedbackFragment : BaseFragment() { } } - data class Screenshot(val byteArray: ByteArray) : Parcelable { - constructor(parcel: Parcel) : this(parcel.createByteArray() ?: byteArrayOf()) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Screenshot - - if (!byteArray.contentEquals(other.byteArray)) return false - - return true - } - - override fun hashCode(): Int { - return byteArray.contentHashCode() - } - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeByteArray(byteArray) - } - - override fun describeContents(): Int { - return 0 - } - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel) = Screenshot(parcel) - override fun newArray(size: Int): Array = arrayOfNulls(size) + private fun subscribeToSendFeedback() { + setFragmentResultListener(SendFeedbackDialogFragment.REQUEST_KEY_RESULT) { _, bundle -> + val isSent = bundle.getBoolean(SendFeedbackDialogFragment.KEY_RESULT) + if (isSent) { + toast(getString(R.string.thank_you_for_feedback)) + navController?.navigateUp() + } } } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/dialog/SendFeedbackDialogFragment.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/dialog/SendFeedbackDialogFragment.kt new file mode 100644 index 0000000000..c76f6e4f9e --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/dialog/SendFeedbackDialogFragment.kt @@ -0,0 +1,123 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.ui.activity.fragment.dialog + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.navArgs +import com.flowcrypt.email.R +import com.flowcrypt.email.api.retrofit.response.base.Result +import com.flowcrypt.email.databinding.FragmentSendFeedbackDialogBinding +import com.flowcrypt.email.extensions.countingIdlingResource +import com.flowcrypt.email.extensions.decrementSafely +import com.flowcrypt.email.extensions.gone +import com.flowcrypt.email.extensions.incrementSafely +import com.flowcrypt.email.extensions.navController +import com.flowcrypt.email.extensions.visible +import com.flowcrypt.email.jetpack.viewmodel.SendFeedbackViewModel +import com.flowcrypt.email.util.exception.ApiException + +/** + * @author Denis Bondarenko + * Date: 8/8/22 + * Time: 1:37 PM + * E-mail: DenBond7@gmail.com + */ +class SendFeedbackDialogFragment : BaseDialogFragment() { + private var binding: FragmentSendFeedbackDialogBinding? = null + private val args by navArgs() + private val sendFeedbackViewModel: SendFeedbackViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setupSendFeedbackViewModel() + sendFeedback() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = FragmentSendFeedbackDialogBinding.inflate( + LayoutInflater.from(requireContext()), + if ((view != null) and (view is ViewGroup)) view as ViewGroup? else null, + false + ) + + binding?.btRetry?.setOnClickListener { + sendFeedback() + } + + val builder = AlertDialog.Builder(requireContext()).apply { + setView(binding?.root) + setNegativeButton(R.string.cancel) { _, _ -> + navController?.navigateUp() + } + } + + return builder.create() + } + + private fun sendFeedback() { + sendFeedbackViewModel.postFeedback(args.accountEntity, args.feedbackMsg, args.screenshot) + } + + private fun setupSendFeedbackViewModel() { + lifecycleScope.launchWhenStarted { + sendFeedbackViewModel.postFeedbackStateFlow.collect { + when (it.status) { + Result.Status.LOADING -> { + countingIdlingResource?.incrementSafely() + binding?.pBLoading?.visible() + binding?.btRetry?.gone() + binding?.tVStatusMessage?.textAlignment = View.TEXT_ALIGNMENT_CENTER + binding?.tVStatusMessage?.text = it.progressMsg + } + + Result.Status.SUCCESS -> { + navController?.navigateUp() + setFragmentResult( + REQUEST_KEY_RESULT, + bundleOf(KEY_RESULT to (it.data?.isSent == true)) + ) + countingIdlingResource?.decrementSafely() + } + + Result.Status.EXCEPTION, Result.Status.ERROR -> { + binding?.pBLoading?.gone() + binding?.btRetry?.visible() + + val exception = it.exception ?: ApiException(it.data?.apiError) + val errorMsg = if (exception.message.isNullOrEmpty()) { + exception.javaClass.simpleName + } else exception.message + + binding?.tVStatusMessage?.textAlignment = View.TEXT_ALIGNMENT_TEXT_START + binding?.tVStatusMessage?.text = getString( + R.string.send_feedback_failed_hint, + getString(R.string.support_email), + errorMsg + ) + + countingIdlingResource?.decrementSafely() + } + + else -> {} + } + } + } + } + + companion object { + const val REQUEST_KEY_RESULT = "REQUEST_KEY_RESULT" + const val KEY_RESULT = "KEY_RESULT" + } +} diff --git a/FlowCrypt/src/main/res/layout/fragment_send_feedback_dialog.xml b/FlowCrypt/src/main/res/layout/fragment_send_feedback_dialog.xml new file mode 100644 index 0000000000..a55dd5494c --- /dev/null +++ b/FlowCrypt/src/main/res/layout/fragment_send_feedback_dialog.xml @@ -0,0 +1,49 @@ + + + + + + + +