diff --git a/app/build.gradle b/app/build.gradle index 5cc627758804..11703e9d614f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -78,14 +78,15 @@ android { ext { androidX = "1.0.2" - materialDesign = "1.1.0-alpha02" + materialDesign = "1.0.0" architectureComponents = "1.1.1" architectureComponentsExtensions = "1.1.1" androidKtx = "1.0.1" + fragmentKtx = "1.0.0" constraintLayout = "2.0.0-alpha3" lifecycle = "2.0.0" - room = "2.1.0-alpha04" - workManager = "1.0.0-beta03" + room = "2.1.0-alpha05" + workManager = "2.0.0" legacySupport = "1.0.0" espressoCore = "3.1.1" coreTesting = "2.0.0" @@ -101,7 +102,7 @@ ext { okHttp = "3.10.0" rxJava = "2.1.10" rxAndroid = "2.0.2" - timber = "4.6.1" + timber = "4.7.1" rxRelay = "2.0.0" leakCanary = "1.6.2" mockito = "2.18.3" @@ -112,6 +113,8 @@ ext { dependencies { implementation "androidx.legacy:legacy-support-v4:$legacySupport" + implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0-beta01' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.0.0' debugImplementation "com.squareup.leakcanary:leakcanary-android:$leakCanary" releaseImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$leakCanary" @@ -124,6 +127,7 @@ dependencies { implementation "com.squareup.retrofit2:retrofit:$retrofit" implementation "com.squareup.retrofit2:converter-moshi:$retrofit" implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofit" + implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2' implementation "io.reactivex.rxjava2:rxjava:$rxJava" implementation "io.reactivex.rxjava2:rxandroid:$rxAndroid" implementation "com.jakewharton.timber:timber:$timber" @@ -139,6 +143,7 @@ dependencies { // Android KTX implementation "androidx.core:core-ktx:$androidKtx" + implementation "androidx.fragment:fragment-ktx:$fragmentKtx" // ViewModel and LiveData implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle" @@ -153,7 +158,7 @@ dependencies { androidTestImplementation "androidx.room:room-testing:$room" // WorkManager - implementation "android.arch.work:work-runtime-ktx:$workManager" + implementation "androidx.work:work-runtime-ktx:$workManager" // Dagger kapt "com.google.dagger:dagger-android-processor:$dagger" diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index e15b41d6d3d7..0e891465c3b0 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -40,7 +40,7 @@ import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.ui.CtaViewModel -import com.duckduckgo.app.feedback.db.SurveyDao +import com.duckduckgo.app.survey.db.SurveyDao import com.duckduckgo.app.global.db.AppConfigurationDao import com.duckduckgo.app.global.db.AppConfigurationEntity import com.duckduckgo.app.global.db.AppDatabase diff --git a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt index 217b92d64605..2ccf0b0a8997 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt @@ -23,13 +23,13 @@ import com.duckduckgo.app.InstantSchedulersRule import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.CtaId import com.duckduckgo.app.cta.model.DismissedCta -import com.duckduckgo.app.feedback.db.SurveyDao -import com.duckduckgo.app.feedback.model.Survey -import com.duckduckgo.app.feedback.model.Survey.Status.SCHEDULED import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.* +import com.duckduckgo.app.survey.db.SurveyDao +import com.duckduckgo.app.survey.model.Survey +import com.duckduckgo.app.survey.model.Survey.Status.SCHEDULED import com.duckduckgo.app.widget.ui.WidgetCapabilities import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.eq diff --git a/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/BrokenSiteViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/BrokenSiteViewModelTest.kt new file mode 100644 index 000000000000..fdd56faeee7c --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/BrokenSiteViewModelTest.kt @@ -0,0 +1,128 @@ +package com.duckduckgo.app.feedback.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer +import com.duckduckgo.app.InstantSchedulersRule +import com.duckduckgo.app.brokensite.BrokenSiteViewModel +import com.duckduckgo.app.brokensite.BrokenSiteViewModel.Command +import com.duckduckgo.app.brokensite.api.BrokenSiteSender +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +class BrokenSiteViewModelTest { + + @get:Rule + @Suppress("unused") + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + @Suppress("unused") + val schedulers = InstantSchedulersRule() + + @Mock + private lateinit var mockBrokenSiteSender: BrokenSiteSender + + @Mock + private lateinit var mockCommandObserver: Observer + + private lateinit var testee: BrokenSiteViewModel + + private val viewState: BrokenSiteViewModel.ViewState + get() = testee.viewState.value!! + + @Before + fun before() { + MockitoAnnotations.initMocks(this) + testee = BrokenSiteViewModel(mockBrokenSiteSender) + testee.command.observeForever(mockCommandObserver) + } + + @After + fun after() { + testee.command.removeObserver(mockCommandObserver) + } + + @Test + fun whenInitializedThenCannotSubmit() { + assertFalse(viewState.submitAllowed) + } + + @Test + fun whenNoUrlProvidedThenUrlFocused() { + testee.setInitialBrokenSite(null) + verify(mockCommandObserver).onChanged(Command.FocusUrl) + } + + @Test + fun whenUrlProvidedThenMessageFocused() { + testee.setInitialBrokenSite(url) + verify(mockCommandObserver).onChanged(Command.FocusMessage) + } + + @Test + fun whenUrlAndMessageNotEmptyThenCanSubmit() { + testee.onBrokenSiteUrlChanged(url) + testee.onFeedbackMessageChanged(message) + assertTrue(viewState.submitAllowed) + } + + @Test + fun whenNullUrlThenCannotSubmit() { + testee.onBrokenSiteUrlChanged(null) + testee.onFeedbackMessageChanged(message) + assertFalse(viewState.submitAllowed) + } + + @Test + fun whenEmptyUrlThenCannotSubmit() { + testee.onBrokenSiteUrlChanged(" ") + testee.onFeedbackMessageChanged(message) + assertFalse(viewState.submitAllowed) + } + + @Test + fun whenNullMessageThenCannotSubmit() { + testee.onBrokenSiteUrlChanged(url) + testee.onFeedbackMessageChanged(null) + assertFalse(viewState.submitAllowed) + } + + @Test + fun whenEmptyMessageThenCannotSubmit() { + testee.onBrokenSiteUrlChanged(url) + testee.onFeedbackMessageChanged(" ") + assertFalse(viewState.submitAllowed) + } + + @Test + fun whenCanSubmitBrokenSiteAndSubmitPressedThenFeedbackSubmitted() { + testee.onBrokenSiteUrlChanged(url) + testee.onFeedbackMessageChanged(message) + testee.onSubmitPressed() + + verify(mockBrokenSiteSender).submitBrokenSiteFeedback(message, url) + verify(mockCommandObserver).onChanged(Command.ConfirmAndFinish) + } + + @Test + fun whenCannotSubmitBrokenSiteAndSubmitPressedThenFeedbackNotSubmitted() { + testee.onSubmitPressed() + verify(mockBrokenSiteSender, never()).submitBrokenSiteFeedback(any(), any()) + verify(mockCommandObserver, never()).onChanged(Command.ConfirmAndFinish) + } + + companion object Constants { + private const val url = "http://example.com" + private const val message = "Feedback message" + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/FeedbackViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/FeedbackViewModelTest.kt deleted file mode 100644 index 91295a8140d8..000000000000 --- a/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/FeedbackViewModelTest.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.duckduckgo.app.feedback.ui - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.Observer -import com.duckduckgo.app.InstantSchedulersRule -import com.duckduckgo.app.feedback.api.FeedbackSender -import com.duckduckgo.app.feedback.ui.FeedbackViewModel.Command -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.never -import com.nhaarman.mockitokotlin2.verify -import org.junit.After -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations - -class FeedbackViewModelTest { - - @get:Rule - @Suppress("unused") - var instantTaskExecutorRule = InstantTaskExecutorRule() - - @get:Rule - @Suppress("unused") - val schedulers = InstantSchedulersRule() - - @Mock - private lateinit var mockFeedbackSender: FeedbackSender - - @Mock - private lateinit var mockCommandObserver: Observer - - private lateinit var testee: FeedbackViewModel - - private val viewState: FeedbackViewModel.ViewState - get() = testee.viewState.value!! - - @Before - fun before() { - MockitoAnnotations.initMocks(this) - testee = FeedbackViewModel(mockFeedbackSender) - testee.command.observeForever(mockCommandObserver) - } - - @After - fun after() { - testee.command.removeObserver(mockCommandObserver) - } - - @Test - fun whenInitializedThenCannotSubmit() { - assertFalse(viewState.submitAllowed) - } - - @Test - fun whenBrokenUrlSwitchedOnWithNoUrlThenUrlFocused() { - testee.onBrokenSiteUrlChanged(null) - testee.onBrokenSiteChanged(true) - verify(mockCommandObserver).onChanged(Command.FocusUrl) - } - - @Test - fun whenBrokenUrlSwitchedOnWithUrlThenMessageFocused() { - testee.onBrokenSiteUrlChanged(url) - testee.onBrokenSiteChanged(true) - verify(mockCommandObserver).onChanged(Command.FocusMessage) - } - - @Test - fun whenBrokenUrlOnWithUrlAndMessageThenCanSubmit() { - testee.onBrokenSiteChanged(true) - testee.onBrokenSiteUrlChanged(url) - testee.onFeedbackMessageChanged(message) - assertTrue(viewState.submitAllowed) - } - - @Test - fun whenBrokenUrlOnWithNullUrlThenCannotSubmit() { - testee.onBrokenSiteChanged(true) - testee.onBrokenSiteUrlChanged(null) - testee.onFeedbackMessageChanged(message) - assertFalse(viewState.submitAllowed) - } - - @Test - fun whenBrokenUrlOnWithNullMessageThenCannotSubmit() { - testee.onBrokenSiteChanged(true) - testee.onBrokenSiteUrlChanged(url) - testee.onFeedbackMessageChanged(null) - assertFalse(viewState.submitAllowed) - } - - @Test - fun whenBrokenUrlOnWithBlankUrlThenCannotSubmit() { - testee.onBrokenSiteChanged(true) - testee.onBrokenSiteUrlChanged(" ") - testee.onFeedbackMessageChanged(message) - assertFalse(viewState.submitAllowed) - } - - @Test - fun whenBrokenUrlOnWithBlankMessageThenCannotSubmit() { - testee.onBrokenSiteChanged(true) - testee.onBrokenSiteUrlChanged(url) - testee.onFeedbackMessageChanged(" ") - assertFalse(viewState.submitAllowed) - } - - @Test - fun whenBrokenUrlOffWithMessageThenCanSubmit() { - testee.onBrokenSiteChanged(false) - testee.onFeedbackMessageChanged(message) - assertTrue(viewState.submitAllowed) - } - - @Test - fun whenBrokenUrlOffWithNullMessageThenCannotSubmit() { - testee.onBrokenSiteChanged(false) - testee.onFeedbackMessageChanged(null) - assertFalse(viewState.submitAllowed) - } - - @Test - fun whenBrokenUrlOffWithBlankMessageThenCannotSubmit() { - testee.onBrokenSiteChanged(false) - testee.onFeedbackMessageChanged(" ") - assertFalse(viewState.submitAllowed) - } - - @Test - fun whenCanSubmitBrokenSiteAndSubmitPressedThenFeedbackSubmitted() { - testee.onBrokenSiteChanged(true) - testee.onBrokenSiteUrlChanged(url) - testee.onFeedbackMessageChanged(message) - testee.onSubmitPressed() - - verify(mockFeedbackSender).submitBrokenSiteFeedback(message, url) - verify(mockCommandObserver).onChanged(Command.ConfirmAndFinish) - } - - @Test - fun whenCannotSubmitBrokenSiteAndSubmitPressedThenFeedbackNotSubmitted() { - testee.onBrokenSiteChanged(true) - testee.onSubmitPressed() - verify(mockFeedbackSender, never()).submitBrokenSiteFeedback(any(), any()) - verify(mockCommandObserver, never()).onChanged(Command.ConfirmAndFinish) - } - - @Test - fun whenCanSubmitMessageAndSubmitPressedThenFeedbackSubmitted() { - testee.onBrokenSiteChanged(false) - testee.onFeedbackMessageChanged(message) - testee.onSubmitPressed() - verify(mockFeedbackSender).submitGeneralFeedback(message) - verify(mockCommandObserver).onChanged(Command.ConfirmAndFinish) - } - - @Test - fun whenCannotSubmitMessageAndSubmitPressedThenFeedbackNotSubmitted() { - testee.onBrokenSiteChanged(false) - testee.onSubmitPressed() - verify(mockFeedbackSender, never()).submitGeneralFeedback(any()) - verify(mockCommandObserver, never()).onChanged(Command.ConfirmAndFinish) - } - - companion object Constants { - private const val url = "http://example.com" - private const val message = "Feedback message" - } - -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/common/FeedbackViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/common/FeedbackViewModelTest.kt new file mode 100644 index 000000000000..3cb0128b2942 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/common/FeedbackViewModelTest.kt @@ -0,0 +1,314 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.common + +import androidx.lifecycle.Observer +import androidx.test.annotation.UiThreadTest +import com.duckduckgo.app.feedback.api.FeedbackSubmitter +import com.duckduckgo.app.feedback.ui.common.FragmentState.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MissingBrowserFeaturesSubReasons.TAB_MANAGEMENT +import com.duckduckgo.app.playstore.PlayStoreUtils +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@Suppress("RemoveExplicitTypeArguments") +class FeedbackViewModelTest { + + private lateinit var testee: FeedbackViewModel + + private val playStoreUtils: PlayStoreUtils = mock() + private val feedbackSubmitter: FeedbackSubmitter = mock() + + private val commandObserver: Observer = mock() + private val commandCaptor = argumentCaptor() + + private val updateViewCommand + get() = testee.updateViewCommand.value?.fragmentViewState + + @Before + @UiThreadTest + fun setup() { + testee = FeedbackViewModel(playStoreUtils, feedbackSubmitter) + testee.command.observeForever(commandObserver) + } + + @After + @UiThreadTest + fun tearDown() { + testee.command.removeObserver(commandObserver) + } + + @UiThreadTest + @Test + fun whenInitialisedThenFragmentStateIsForFirstStep() { + assertTrue(updateViewCommand is InitialAppEnjoymentClarifier) + } + + @Test + @UiThreadTest + fun whenCanRateAppAndUserSelectsInitialHappyFaceThenFragmentStateIsFirstStepOfHappyFlow() { + configureRatingCanBeGiven() + testee.userSelectedPositiveFeedback() + assertTrue(updateViewCommand is FragmentState.PositiveFeedbackFirstStep) + verifyForwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenCannotRateAppAndUserSelectsInitialHappyFaceThenFragmentStateSkipsStraightToSharingFeedback() { + configureRatingCannotBeGiven() + testee.userSelectedPositiveFeedback() + assertTrue(updateViewCommand is FragmentState.PositiveShareFeedback) + verifyForwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenCanRateAppAndUserNavigatesBackFromPositiveInitialFragmentThenFragmentStateIsInitialFragment() { + configureRatingCanBeGiven() + testee.userSelectedPositiveFeedback() + testee.onBackPressed() + + assertTrue(updateViewCommand is InitialAppEnjoymentClarifier) + verifyBackwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenCannotRateAppAndUserNavigatesBackFromPositiveInitialFragmentThenFragmentStateIsInitialFragment() { + configureRatingCannotBeGiven() + testee.userSelectedPositiveFeedback() + testee.onBackPressed() + + assertTrue(updateViewCommand is InitialAppEnjoymentClarifier) + } + + @Test + fun whenUserChoosesNotToProvideFurtherDetailsForPositiveFeedbackThenSubmitted() = runBlocking { + withContext(Dispatchers.Main) { + testee.userGavePositiveFeedbackNoDetails() + } + + verify(feedbackSubmitter).sendPositiveFeedback(null) + } + + @Test + fun whenUserChoosesNotToProvideFurtherDetailsForPositiveFeedbackThenExitCommandIssued() = runBlocking { + withContext(Dispatchers.Main) { + testee.userGavePositiveFeedbackNoDetails() + } + + val command = captureCommand() as Command.Exit + assertTrue(command.feedbackSubmitted) + } + + @Test + fun whenUserProvidesFurtherDetailsForPositiveFeedbackThenFeedbackSubmitted() = runBlocking { + withContext(Dispatchers.Main) { + testee.userProvidedPositiveOpenEndedFeedback("foo") + } + + verify(feedbackSubmitter).sendPositiveFeedback("foo") + } + + @Test + fun whenUserProvidesFurtherDetailsForPositiveFeedbackThenExitCommandIssued() = runBlocking { + withContext(Dispatchers.Main) { + testee.userProvidedPositiveOpenEndedFeedback("foo") + } + + val command = captureCommand() as Command.Exit + assertTrue(command.feedbackSubmitted) + } + + @Test + fun whenUserProvidesNegativeFeedbackThenFeedbackSubmitted() = runBlocking { + withContext(Dispatchers.Main) { + testee.userProvidedNegativeOpenEndedFeedback(MISSING_BROWSING_FEATURES, TAB_MANAGEMENT, "foo") + } + verify(feedbackSubmitter).sendNegativeFeedback(MISSING_BROWSING_FEATURES, TAB_MANAGEMENT, "foo") + } + + @Test + fun whenUserProvidesNegativeFeedbackNoSubReasonThenFeedbackSubmitted() = runBlocking { + withContext(Dispatchers.Main) { + testee.userProvidedNegativeOpenEndedFeedback(MISSING_BROWSING_FEATURES, null, "foo") + } + verify(feedbackSubmitter).sendNegativeFeedback(MISSING_BROWSING_FEATURES, null, "foo") + } + + @Test + fun whenUserProvidesNegativeFeedbackEmptyOpenEndedFeedbackThenFeedbackSubmitted() = runBlocking { + withContext(Dispatchers.Main) { + testee.userProvidedNegativeOpenEndedFeedback(MISSING_BROWSING_FEATURES, null, "") + } + verify(feedbackSubmitter).sendNegativeFeedback(MISSING_BROWSING_FEATURES, null, "") + } + + @Test + @UiThreadTest + fun whenUserCancelsThenExitCommandIssued() { + testee.userWantsToCancel() + val command = captureCommand() as Command.Exit + assertFalse(command.feedbackSubmitted) + } + + @Test + @UiThreadTest + fun whenUserSelectsInitialSadFaceThenFragmentStateIsFirstStepOfUnhappyFlow() { + testee.userSelectedNegativeFeedback() + assertTrue(updateViewCommand is NegativeFeedbackMainReason) + verifyForwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenUserNavigatesBackFromNegativeMainReasonFragmentThenFragmentStateIsInitialFragment() { + testee.userSelectedNegativeFeedback() + testee.onBackPressed() + assertTrue(updateViewCommand is InitialAppEnjoymentClarifier) + verifyBackwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenUserSelectsMainNegativeReasonMissingBrowserFeaturesThenFragmentStateIsSubReasonSelection() { + testee.userSelectedNegativeFeedbackMainReason(MISSING_BROWSING_FEATURES) + assertTrue(updateViewCommand is NegativeFeedbackSubReason) + verifyForwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenUserSelectsMainNegativeReasonNotEnoughCustomizationsThenFragmentStateIsSubReasonSelection() { + testee.userSelectedNegativeFeedbackMainReason(NOT_ENOUGH_CUSTOMIZATIONS) + assertTrue(updateViewCommand is NegativeFeedbackSubReason) + verifyForwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenUserSelectsMainNegativeReasonSearchNotGoodEnoughThenFragmentStateIsSubReasonSelection() { + testee.userSelectedNegativeFeedbackMainReason(SEARCH_NOT_GOOD_ENOUGH) + assertTrue(updateViewCommand is NegativeFeedbackSubReason) + verifyForwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenUserSelectsMainNegativeReasonAppIsSlowOrBuggyThenFragmentStateIsSubReasonSelection() { + testee.userSelectedNegativeFeedbackMainReason(APP_IS_SLOW_OR_BUGGY) + assertTrue(updateViewCommand is NegativeFeedbackSubReason) + verifyForwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenUserSelectsMainNegativeReasonOtherThenFragmentStateIsOpenEndedFeedback() { + testee.userSelectedNegativeFeedbackMainReason(OTHER) + assertTrue(updateViewCommand is NegativeOpenEndedFeedback) + verifyForwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenUserSelectsMainNegativeReasonBrokenSiteThenFragmentStateIsSubReasonSelection() { + testee.userSelectedNegativeFeedbackMainReason(WEBSITES_NOT_LOADING) + assertTrue(updateViewCommand is NegativeWebSitesBrokenFeedback) + verifyForwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenUserSelectsSubNegativeReasonThenFragmentStateIsOpenEndedFeedback() { + testee.userSelectedSubReasonMissingBrowserFeatures(MISSING_BROWSING_FEATURES, TAB_MANAGEMENT) + assertTrue(updateViewCommand is NegativeOpenEndedFeedback) + verifyForwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenUserNavigatesBackFromSubReasonSelectionThenFragmentStateIsMainReasonSelection() { + testee.userSelectedNegativeFeedbackMainReason(MISSING_BROWSING_FEATURES) + testee.onBackPressed() + assertTrue(updateViewCommand is NegativeFeedbackMainReason) + verifyBackwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenUserNavigatesBackFromOpenEndedFeedbackAndSubReasonIsValidStepThenFragmentStateIsSubReasonSelection() { + testee.userSelectedNegativeFeedbackMainReason(MISSING_BROWSING_FEATURES) + testee.userSelectedSubReasonMissingBrowserFeatures(MISSING_BROWSING_FEATURES, TAB_MANAGEMENT) + testee.onBackPressed() + assertTrue(updateViewCommand is NegativeFeedbackSubReason) + verifyBackwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenUserNavigatesBackFromOpenEndedFeedbackAndSubReasonNotAValidStepThenFragmentStateIsMainReasonSelection() { + testee.userSelectedNegativeFeedbackMainReason(OTHER) + testee.onBackPressed() + assertTrue(updateViewCommand is NegativeFeedbackMainReason) + verifyBackwardsNavigation(updateViewCommand) + } + + @Test + @UiThreadTest + fun whenUserNavigatesBackFromOpenEndedFeedbackThenFragmentStateIsSubReasonSelection() { + testee.userSelectedNegativeFeedbackMainReason(MISSING_BROWSING_FEATURES) + testee.userSelectedSubReasonMissingBrowserFeatures(MISSING_BROWSING_FEATURES, TAB_MANAGEMENT) + testee.onBackPressed() + assertTrue(updateViewCommand is NegativeFeedbackSubReason) + verifyBackwardsNavigation(updateViewCommand) + } + + private fun verifyForwardsNavigation(fragmentViewState: FragmentState?) { + assertTrue(fragmentViewState?.forwardDirection == true) + } + + private fun verifyBackwardsNavigation(fragmentViewState: FragmentState?) { + assertTrue(fragmentViewState?.forwardDirection == false) + } + + private fun captureCommand(): Command { + verify(commandObserver).onChanged(commandCaptor.capture()) + return commandCaptor.lastValue + } + + private fun configureRatingCanBeGiven() { + whenever(playStoreUtils.installedFromPlayStore()).thenReturn(true) + whenever(playStoreUtils.isPlayStoreInstalled()).thenReturn(true) + } + + private fun configureRatingCannotBeGiven() { + whenever(playStoreUtils.installedFromPlayStore()).thenReturn(false) + whenever(playStoreUtils.isPlayStoreInstalled()).thenReturn(false) + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/global/rating/InitialPromptTypeDeciderTest.kt b/app/src/androidTest/java/com/duckduckgo/app/global/rating/InitialPromptTypeDeciderTest.kt index 35a2ddfcf096..bded3203b9d0 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/global/rating/InitialPromptTypeDeciderTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/global/rating/InitialPromptTypeDeciderTest.kt @@ -19,7 +19,6 @@ package com.duckduckgo.app.global.rating import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.playstore.PlayStoreUtils import com.duckduckgo.app.usage.search.SearchCountDao -import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.runBlocking @@ -49,14 +48,14 @@ class InitialPromptTypeDeciderTest { InstrumentationRegistry.getInstrumentation().targetContext ) - whenever(mockPlayStoreUtils.isPlayStoreInstalled(any())).thenReturn(true) - whenever(mockPlayStoreUtils.installedFromPlayStore(any())).thenReturn(true) + whenever(mockPlayStoreUtils.isPlayStoreInstalled()).thenReturn(true) + whenever(mockPlayStoreUtils.installedFromPlayStore()).thenReturn(true) whenever(mockSearchCountDao.getSearchesMade()).thenReturn(Long.MAX_VALUE) } @Test fun whenPlayNotInstalledThenNoPromptShown() = runBlocking { - whenever(mockPlayStoreUtils.isPlayStoreInstalled(any())).thenReturn(false) + whenever(mockPlayStoreUtils.isPlayStoreInstalled()).thenReturn(false) assertPromptNotShown(testee.determineInitialPromptType()) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/feedback/api/SurveyDownloaderTest.kt b/app/src/androidTest/java/com/duckduckgo/app/survey/api/SurveyDownloaderTest.kt similarity index 92% rename from app/src/androidTest/java/com/duckduckgo/app/feedback/api/SurveyDownloaderTest.kt rename to app/src/androidTest/java/com/duckduckgo/app/survey/api/SurveyDownloaderTest.kt index 1760ec5fbe39..f947cefae092 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/feedback/api/SurveyDownloaderTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/survey/api/SurveyDownloaderTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2019 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,13 +14,13 @@ * limitations under the License. */ -package com.duckduckgo.app.feedback.api +package com.duckduckgo.app.survey.api import com.duckduckgo.app.InstantSchedulersRule -import com.duckduckgo.app.feedback.db.SurveyDao -import com.duckduckgo.app.feedback.model.Survey -import com.duckduckgo.app.feedback.model.Survey.Status.NOT_ALLOCATED -import com.duckduckgo.app.feedback.model.Survey.Status.SCHEDULED +import com.duckduckgo.app.survey.db.SurveyDao +import com.duckduckgo.app.survey.model.Survey +import com.duckduckgo.app.survey.model.Survey.Status.NOT_ALLOCATED +import com.duckduckgo.app.survey.model.Survey.Status.SCHEDULED import com.nhaarman.mockitokotlin2.* import okhttp3.ResponseBody import org.junit.Rule diff --git a/app/src/androidTest/java/com/duckduckgo/app/feedback/db/SurveyDaoTest.kt b/app/src/androidTest/java/com/duckduckgo/app/survey/db/SurveyDaoTest.kt similarity index 94% rename from app/src/androidTest/java/com/duckduckgo/app/feedback/db/SurveyDaoTest.kt rename to app/src/androidTest/java/com/duckduckgo/app/survey/db/SurveyDaoTest.kt index 0a138277e4c3..16667cb0e044 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/feedback/db/SurveyDaoTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/survey/db/SurveyDaoTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2019 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,13 +14,13 @@ * limitations under the License. */ -package com.duckduckgo.app.feedback.db +package com.duckduckgo.app.survey.db import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation -import com.duckduckgo.app.feedback.model.Survey -import com.duckduckgo.app.feedback.model.Survey.Status.* import com.duckduckgo.app.global.db.AppDatabase +import com.duckduckgo.app.survey.model.Survey +import com.duckduckgo.app.survey.model.Survey.Status.* import org.junit.After import org.junit.Assert.* import org.junit.Before diff --git a/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/SurveyViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/survey/ui/SurveyViewModelTest.kt similarity index 92% rename from app/src/androidTest/java/com/duckduckgo/app/feedback/ui/SurveyViewModelTest.kt rename to app/src/androidTest/java/com/duckduckgo/app/survey/ui/SurveyViewModelTest.kt index c42e33e3b0a2..1c0c410f3636 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/SurveyViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/survey/ui/SurveyViewModelTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2019 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.app.feedback.ui +package com.duckduckgo.app.survey.ui import android.os.Build import androidx.arch.core.executor.testing.InstantTaskExecutorRule @@ -22,14 +22,14 @@ import androidx.core.net.toUri import androidx.lifecycle.Observer import com.duckduckgo.app.InstantSchedulersRule import com.duckduckgo.app.browser.BuildConfig -import com.duckduckgo.app.feedback.db.SurveyDao -import com.duckduckgo.app.feedback.model.Survey -import com.duckduckgo.app.feedback.model.Survey.Status.DONE -import com.duckduckgo.app.feedback.model.Survey.Status.SCHEDULED -import com.duckduckgo.app.feedback.ui.SurveyViewModel.Command import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.app.survey.db.SurveyDao +import com.duckduckgo.app.survey.model.Survey +import com.duckduckgo.app.survey.model.Survey.Status.DONE +import com.duckduckgo.app.survey.model.Survey.Status.SCHEDULED +import com.duckduckgo.app.survey.ui.SurveyViewModel.Command import com.nhaarman.mockitokotlin2.* import org.junit.After import org.junit.Assert.assertEquals diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 84ae2ce61904..d045c6bfb9e9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -108,11 +108,15 @@ android:label="@string/settingsActivityTitle" android:parentActivityName=".BrowserActivity" /> + android:name="com.duckduckgo.app.feedback.ui.common.FeedbackActivity" + android:windowSoftInputMode="adjustResize" + android:label="@string/feedbackActivityTitle" /> + - viewModel.onBrokenSiteChanged(isChecked) - } feedbackMessage.addTextChangedListener(object : TextChangedWatcher() { override fun afterTextChanged(editable: Editable) { viewModel.onFeedbackMessageChanged(editable.toString()) @@ -78,12 +71,12 @@ class FeedbackActivity : DuckDuckGoActivity() { viewModel.command.observe(this, Observer { it?.let { processCommand(it) } }) - viewModel.viewState.observe(this, Observer { + viewModel.viewState.observe(this, Observer { it?.let { render(it) } }) } - private fun processCommand(command: Command) { + private fun processCommand(command: BrokenSiteViewModel.Command) { when (command) { Command.FocusUrl -> brokenSiteUrl.requestFocus() Command.FocusMessage -> feedbackMessage.requestFocus() @@ -92,16 +85,12 @@ class FeedbackActivity : DuckDuckGoActivity() { } private fun confirmAndFinish() { - longToast(R.string.feedbackSubmitted) + longToast(R.string.brokenSiteSubmitted) finishAfterTransition() } private fun render(viewState: ViewState) { - val messageHint = if (viewState.isBrokenSite) R.string.feedbackBrokenSiteHint else R.string.feedbackMessageHint - brokenSiteSwitch.isChecked = viewState.isBrokenSite - brokenSiteUrl.isVisible = viewState.showUrl brokenSiteUrl.updateText(viewState.url ?: "") - feedbackMessage.setHint(messageHint) submitButton.isEnabled = viewState.submitAllowed } @@ -113,12 +102,10 @@ class FeedbackActivity : DuckDuckGoActivity() { companion object { - private const val BROKEN_SITE_EXTRA = "BROKEN_SITE_EXTRA" private const val URL_EXTRA = "URL_EXTRA" - fun intent(context: Context, brokenSite: Boolean = false, url: String? = null): Intent { - val intent = Intent(context, FeedbackActivity::class.java) - intent.putExtra(BROKEN_SITE_EXTRA, brokenSite) + fun intent(context: Context, url: String? = null): Intent { + val intent = Intent(context, BrokenSiteActivity::class.java) if (url != null) { intent.putExtra(URL_EXTRA, url) } diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/FeedbackViewModel.kt b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt similarity index 61% rename from app/src/main/java/com/duckduckgo/app/feedback/ui/FeedbackViewModel.kt rename to app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt index d55d68c0f0a6..872ad7ac5a54 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/ui/FeedbackViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2019 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,20 +14,18 @@ * limitations under the License. */ -package com.duckduckgo.app.feedback.ui +package com.duckduckgo.app.brokensite import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.duckduckgo.app.feedback.api.FeedbackSender +import com.duckduckgo.app.brokensite.api.BrokenSiteSender import com.duckduckgo.app.global.SingleLiveEvent -class FeedbackViewModel(private val feedbackSender: FeedbackSender) : ViewModel() { +class BrokenSiteViewModel(private val brokenSiteSender: BrokenSiteSender) : ViewModel() { data class ViewState( - val isBrokenSite: Boolean = false, val url: String? = null, - val showUrl: Boolean = false, val message: String? = null, val submitAllowed: Boolean = false ) @@ -49,21 +47,8 @@ class FeedbackViewModel(private val feedbackSender: FeedbackSender) : ViewModel( fun setInitialBrokenSite(url: String?) { onBrokenSiteUrlChanged(url) - onBrokenSiteChanged(true) - } - - fun onBrokenSiteChanged(isBroken: Boolean) { - if (isBroken == viewState.value?.isBrokenSite) { - return - } - - viewState.value = viewState.value?.copy( - isBrokenSite = isBroken, - showUrl = isBroken, - submitAllowed = canSubmit(isBroken, viewValue.url, viewValue.message) - ) - if (isBroken && viewValue.url.isNullOrBlank()) { + if (viewValue.url.isNullOrBlank()) { command.value = Command.FocusUrl } else { command.value = Command.FocusMessage @@ -73,24 +58,24 @@ class FeedbackViewModel(private val feedbackSender: FeedbackSender) : ViewModel( fun onBrokenSiteUrlChanged(newUrl: String?) { viewState.value = viewState.value?.copy( url = newUrl, - submitAllowed = canSubmit(viewValue.isBrokenSite, newUrl, viewValue.message) + submitAllowed = canSubmit(newUrl, viewValue.message) ) } fun onFeedbackMessageChanged(newMessage: String?) { viewState.value = viewState.value?.copy( message = newMessage, - submitAllowed = canSubmit(viewValue.isBrokenSite, viewValue.url, newMessage) + submitAllowed = canSubmit(viewValue.url, newMessage) ) } - private fun canSubmit(isBrokenSite: Boolean, url: String?, feedbackMessage: String?): Boolean { + private fun canSubmit(url: String?, feedbackMessage: String?): Boolean { if (feedbackMessage.isNullOrBlank()) { return false } - if (isBrokenSite && url.isNullOrBlank()) { + if (url.isNullOrBlank()) { return false } @@ -98,17 +83,10 @@ class FeedbackViewModel(private val feedbackSender: FeedbackSender) : ViewModel( } fun onSubmitPressed() { - val message = viewValue.message ?: return + val url = viewValue.url ?: return - if (viewValue.isBrokenSite) { - val url = viewValue.url ?: return - feedbackSender.submitBrokenSiteFeedback(message, url) - } else { - feedbackSender.submitGeneralFeedback(message) - } - + brokenSiteSender.submitBrokenSiteFeedback(message, url) command.value = Command.ConfirmAndFinish } - } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSender.kt b/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt similarity index 50% rename from app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSender.kt rename to app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt index da966783c90a..f2a509b1eaef 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSender.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2019 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,56 +14,45 @@ * limitations under the License. */ -package com.duckduckgo.app.feedback.api +package com.duckduckgo.app.brokensite.api -import android.annotation.SuppressLint import android.os.Build import com.duckduckgo.app.browser.BuildConfig -import com.duckduckgo.app.feedback.api.FeedbackService.Platform -import com.duckduckgo.app.feedback.api.FeedbackService.Reason +import com.duckduckgo.app.feedback.api.FeedbackService import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.store.StatisticsDataStore -import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import timber.log.Timber -interface FeedbackSender { - fun submitGeneralFeedback(comment: String) +interface BrokenSiteSender { fun submitBrokenSiteFeedback(comment: String, url: String) } -class FeedbackSubmitter( +class BrokenSiteSubmitter( private val statisticsStore: StatisticsDataStore, private val variantManager: VariantManager, private val service: FeedbackService -) : FeedbackSender { - - @SuppressLint("CheckResult") - override fun submitGeneralFeedback(comment: String) { - submitFeedback(Reason.GENERAL, comment, "") - } +) : BrokenSiteSender { override fun submitBrokenSiteFeedback(comment: String, url: String) { - submitFeedback(Reason.BROKEN_SITE, comment, url) - } - - private fun submitFeedback(type: String, comment: String, url: String) { - service.feedback( - type, - url, - comment, - Platform.ANDROID, - Build.VERSION.SDK_INT, - Build.MANUFACTURER, - Build.MODEL, - BuildConfig.VERSION_NAME, - atbWithVariant() - ) - .subscribeOn(Schedulers.io()) - .subscribe({ - Timber.v("Feedback submission succeeded") - }, { - Timber.w("Feedback submission failed ${it.localizedMessage}") - }) + GlobalScope.launch(Dispatchers.IO) { + + runCatching { + service.submitBrokenSite( + url = url, + comment = comment, + api = Build.VERSION.SDK_INT, + manufacturer = Build.MANUFACTURER, + model = Build.MODEL, + version = BuildConfig.VERSION_NAME, + atb = atbWithVariant() + ).execute() + } + .onSuccess { Timber.v("Feedback submission succeeded") } + .onFailure { Timber.w(it, "Feedback submission failed") } + } } private fun atbWithVariant(): String { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 6313feda1638..208b24d97f2e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -26,13 +26,14 @@ import android.widget.Toast import androidx.fragment.app.DialogFragment import androidx.lifecycle.Observer import com.duckduckgo.app.bookmarks.ui.BookmarksActivity +import com.duckduckgo.app.brokensite.BrokenSiteActivity import com.duckduckgo.app.browser.BrowserViewModel.Command import com.duckduckgo.app.browser.BrowserViewModel.Command.Query import com.duckduckgo.app.browser.BrowserViewModel.Command.Refresh import com.duckduckgo.app.browser.rating.ui.AppEnjoymentDialogFragment import com.duckduckgo.app.browser.rating.ui.GiveFeedbackDialogFragment import com.duckduckgo.app.browser.rating.ui.RateAppDialogFragment -import com.duckduckgo.app.feedback.ui.FeedbackActivity +import com.duckduckgo.app.feedback.ui.common.FeedbackActivity import com.duckduckgo.app.fire.DataClearer import com.duckduckgo.app.global.ApplicationClearDataState import com.duckduckgo.app.global.DuckDuckGoActivity @@ -229,7 +230,7 @@ class BrowserActivity : DuckDuckGoActivity() { is Command.ShowAppEnjoymentPrompt -> showAppEnjoymentPrompt(AppEnjoymentDialogFragment.create(command.promptCount, viewModel)) is Command.ShowAppRatingPrompt -> showAppEnjoymentPrompt(RateAppDialogFragment.create(command.promptCount, viewModel)) is Command.ShowAppFeedbackPrompt -> showAppEnjoymentPrompt(GiveFeedbackDialogFragment.create(command.promptCount, viewModel)) - is Command.LaunchFeedbackView -> startActivity(FeedbackActivity.intent(this, brokenSite = false)) + is Command.LaunchFeedbackView -> startActivity(FeedbackActivity.intent(this)) } } @@ -266,7 +267,7 @@ class BrowserActivity : DuckDuckGoActivity() { fun launchBrokenSiteFeedback(url: String?) { val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - startActivity(FeedbackActivity.intent(this, true, url), options) + startActivity(BrokenSiteActivity.intent(this, url), options) } fun launchSettings() { @@ -360,7 +361,7 @@ class BrowserActivity : DuckDuckGoActivity() { } private fun launchPlayStore() { - playStoreUtils.launchPlayStore(this) + playStoreUtils.launchPlayStore() } private data class CombinedInstanceState(val originalInstanceState: Bundle?, val newInstanceState: Bundle?) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 862c83488d42..bbc94b3cba64 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -74,13 +74,13 @@ import com.duckduckgo.app.browser.shortcut.ShortcutBuilder import com.duckduckgo.app.browser.useragent.UserAgentProvider import com.duckduckgo.app.cta.ui.CtaConfiguration import com.duckduckgo.app.cta.ui.CtaViewModel -import com.duckduckgo.app.feedback.model.Survey -import com.duckduckgo.app.feedback.ui.SurveyActivity import com.duckduckgo.app.global.ViewModelFactory import com.duckduckgo.app.global.view.* import com.duckduckgo.app.privacy.model.PrivacyGrade import com.duckduckgo.app.privacy.renderer.icon import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.survey.model.Survey +import com.duckduckgo.app.survey.ui.SurveyActivity import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.widget.ui.AddWidgetInstructionsActivity import com.duckduckgo.widget.SearchWidgetLight @@ -190,7 +190,7 @@ class BrowserTabFragment : Fragment(), FindListener { private val logoHidingLayoutChangeListener by lazy { LogoHidingLayoutChangeListener(ddgLogo) } - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { AndroidSupportInjection.inject(this) super.onAttach(context) } @@ -527,9 +527,9 @@ class BrowserTabFragment : Fragment(), FindListener { private fun configureOmnibarTextInput() { omnibarTextInput.onFocusChangeListener = - View.OnFocusChangeListener { _, hasFocus: Boolean -> - viewModel.onOmnibarInputStateChanged(omnibarTextInput.text.toString(), hasFocus, false) - } + View.OnFocusChangeListener { _, hasFocus: Boolean -> + viewModel.onOmnibarInputStateChanged(omnibarTextInput.text.toString(), hasFocus, false) + } omnibarTextInput.onBackKeyListener = object : KeyboardAwareEditText.OnBackKeyListener { override fun onBackKey(): Boolean { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index 7048ff5d768c..2c7c627114e9 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -48,7 +48,6 @@ import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.cta.ui.CtaConfiguration import com.duckduckgo.app.cta.ui.CtaViewModel -import com.duckduckgo.app.feedback.model.Survey import com.duckduckgo.app.global.* import com.duckduckgo.app.global.db.AppConfigurationDao import com.duckduckgo.app.global.db.AppConfigurationEntity @@ -60,6 +59,7 @@ import com.duckduckgo.app.privacy.db.SiteVisitedEntity import com.duckduckgo.app.privacy.model.PrivacyGrade import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.api.StatisticsUpdater +import com.duckduckgo.app.survey.model.Survey import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.trackerdetection.model.TrackingEvent diff --git a/app/src/main/java/com/duckduckgo/app/browser/rating/di/RatingModule.kt b/app/src/main/java/com/duckduckgo/app/browser/rating/di/RatingModule.kt index 7e6656de5608..4da7b4a49636 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/rating/di/RatingModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/rating/di/RatingModule.kt @@ -71,8 +71,8 @@ class RatingModule { } @Provides - fun playStoreUtils(): PlayStoreUtils { - return PlayStoreAndroidUtils() + fun playStoreUtils(context: Context): PlayStoreUtils { + return PlayStoreAndroidUtils(context) } @Singleton diff --git a/app/src/main/java/com/duckduckgo/app/browser/rating/ui/EnjoymentDialog.kt b/app/src/main/java/com/duckduckgo/app/browser/rating/ui/EnjoymentDialog.kt index 083e6ee0c9a6..725ad82dcb30 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/rating/ui/EnjoymentDialog.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/rating/ui/EnjoymentDialog.kt @@ -40,7 +40,7 @@ abstract class EnjoymentDialog : DialogFragment() { isCancelable = false } - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { AndroidSupportInjection.inject(this) super.onAttach(context) } diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt index 24c9997afd21..3f184b6ae725 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt @@ -27,12 +27,12 @@ import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.CtaId import com.duckduckgo.app.cta.model.DismissedCta import com.duckduckgo.app.cta.ui.CtaConfiguration.* -import com.duckduckgo.app.feedback.db.SurveyDao -import com.duckduckgo.app.feedback.model.Survey import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.install.daysInstalled import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.* +import com.duckduckgo.app.survey.db.SurveyDao +import com.duckduckgo.app.survey.model.Survey import com.duckduckgo.app.widget.ui.WidgetCapabilities import io.reactivex.schedulers.Schedulers import kotlinx.android.synthetic.main.include_cta_buttons.view.* @@ -147,7 +147,7 @@ sealed class CtaConfiguration( open val cancelPixel: Pixel.PixelName ) { - data class Survey(val survey: com.duckduckgo.app.feedback.model.Survey) : CtaConfiguration( + data class Survey(val survey: com.duckduckgo.app.survey.model.Survey) : CtaConfiguration( CtaId.SURVEY, R.drawable.survey_cta_icon, R.string.surveyCtaTitle, diff --git a/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt b/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt index 57df3964508f..6f6f33a650f4 100644 --- a/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt @@ -18,14 +18,20 @@ package com.duckduckgo.app.di import com.duckduckgo.app.about.AboutDuckDuckGoActivity import com.duckduckgo.app.bookmarks.ui.BookmarksActivity +import com.duckduckgo.app.brokensite.BrokenSiteActivity import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.BrowserTabFragment import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserInfoActivity import com.duckduckgo.app.browser.rating.ui.AppEnjoymentDialogFragment import com.duckduckgo.app.browser.rating.ui.GiveFeedbackDialogFragment import com.duckduckgo.app.browser.rating.ui.RateAppDialogFragment -import com.duckduckgo.app.feedback.ui.FeedbackActivity -import com.duckduckgo.app.feedback.ui.SurveyActivity +import com.duckduckgo.app.feedback.ui.common.FeedbackActivity +import com.duckduckgo.app.feedback.ui.initial.InitialFeedbackFragment +import com.duckduckgo.app.feedback.ui.negative.brokensite.BrokenSiteNegativeFeedbackFragment +import com.duckduckgo.app.feedback.ui.negative.mainreason.MainReasonNegativeFeedbackFragment +import com.duckduckgo.app.feedback.ui.negative.openended.ShareOpenEndedFeedbackFragment +import com.duckduckgo.app.feedback.ui.negative.subreason.SubReasonNegativeFeedbackFragment +import com.duckduckgo.app.feedback.ui.positive.initial.PositiveFeedbackLandingFragment import com.duckduckgo.app.fire.FireActivity import com.duckduckgo.app.job.AppConfigurationJobService import com.duckduckgo.app.launch.LaunchActivity @@ -37,6 +43,7 @@ import com.duckduckgo.app.privacy.ui.PrivacyPracticesActivity import com.duckduckgo.app.privacy.ui.ScorecardActivity import com.duckduckgo.app.privacy.ui.TrackerNetworksActivity import com.duckduckgo.app.settings.SettingsActivity +import com.duckduckgo.app.survey.ui.SurveyActivity import com.duckduckgo.app.tabs.ui.TabSwitcherActivity import com.duckduckgo.app.widget.ui.AddWidgetInstructionsActivity import dagger.Module @@ -84,6 +91,10 @@ abstract class AndroidBindingModule { @ContributesAndroidInjector abstract fun feedbackActivity(): FeedbackActivity + @ActivityScoped + @ContributesAndroidInjector + abstract fun brokenSiteActivity(): BrokenSiteActivity + @ActivityScoped @ContributesAndroidInjector abstract fun userSurveyActivity(): SurveyActivity @@ -129,6 +140,23 @@ abstract class AndroidBindingModule { @ContributesAndroidInjector abstract fun rateAppDialogFragment(): RateAppDialogFragment + @ContributesAndroidInjector + abstract fun initialfFeedbackFragment(): InitialFeedbackFragment + + @ContributesAndroidInjector + abstract fun positiveFeedbackLandingFragment(): PositiveFeedbackLandingFragment + + @ContributesAndroidInjector + abstract fun shareOpenEndedPositiveFeedbackFragment(): ShareOpenEndedFeedbackFragment + + @ContributesAndroidInjector + abstract fun mainReasonNegativeFeedbackFragment(): MainReasonNegativeFeedbackFragment + + @ContributesAndroidInjector + abstract fun disambiguationNegativeFeedbackFragment(): SubReasonNegativeFeedbackFragment + + @ContributesAndroidInjector + abstract fun brokenSiteNegativeFeedbackFragment(): BrokenSiteNegativeFeedbackFragment /* Services */ diff --git a/app/src/main/java/com/duckduckgo/app/di/AppConfigurationDownloadModule.kt b/app/src/main/java/com/duckduckgo/app/di/AppConfigurationDownloadModule.kt index e23c485a022c..8ebf5331b159 100644 --- a/app/src/main/java/com/duckduckgo/app/di/AppConfigurationDownloadModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/AppConfigurationDownloadModule.kt @@ -17,7 +17,7 @@ package com.duckduckgo.app.di import com.duckduckgo.app.entities.api.EntityListDownloader -import com.duckduckgo.app.feedback.api.SurveyDownloader +import com.duckduckgo.app.survey.api.SurveyDownloader import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.httpsupgrade.api.HttpsUpgradeDataDownloader import com.duckduckgo.app.job.AppConfigurationDownloader diff --git a/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt b/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt index d85d7254f52e..a68501c3eab1 100644 --- a/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt @@ -19,11 +19,13 @@ package com.duckduckgo.app.di import android.app.job.JobScheduler import android.content.Context import com.duckduckgo.app.autocomplete.api.AutoCompleteService +import com.duckduckgo.app.brokensite.api.BrokenSiteSender +import com.duckduckgo.app.brokensite.api.BrokenSiteSubmitter import com.duckduckgo.app.entities.api.EntityListService -import com.duckduckgo.app.feedback.api.FeedbackSender import com.duckduckgo.app.feedback.api.FeedbackService import com.duckduckgo.app.feedback.api.FeedbackSubmitter -import com.duckduckgo.app.feedback.api.SurveyService +import com.duckduckgo.app.feedback.api.FireAndForgetFeedbackSubmitter +import com.duckduckgo.app.feedback.api.SubReasonApiMapper import com.duckduckgo.app.global.AppUrl.Url import com.duckduckgo.app.global.api.ApiRequestInterceptor import com.duckduckgo.app.global.job.JobBuilder @@ -31,9 +33,12 @@ import com.duckduckgo.app.httpsupgrade.api.HttpsUpgradeService import com.duckduckgo.app.job.AppConfigurationSyncer import com.duckduckgo.app.job.ConfigurationDownloader import com.duckduckgo.app.statistics.VariantManager +import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.surrogates.api.ResourceSurrogateListService +import com.duckduckgo.app.survey.api.SurveyService import com.duckduckgo.app.trackerdetection.api.TrackerListService +import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory import com.squareup.moshi.Moshi import dagger.Module import dagger.Provides @@ -77,6 +82,7 @@ class NetworkModule { .baseUrl(Url.API) .client(okHttpClient) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .addCallAdapterFactory(CoroutineCallAdapterFactory()) .addConverterFactory(MoshiConverterFactory.create(moshi)) .build() } @@ -99,35 +105,40 @@ class NetworkModule { @Provides fun trackerListService(@Named("api") retrofit: Retrofit): TrackerListService = - retrofit.create(TrackerListService::class.java) + retrofit.create(TrackerListService::class.java) @Provides fun httpsUpgradeService(@Named("api") retrofit: Retrofit): HttpsUpgradeService = - retrofit.create(HttpsUpgradeService::class.java) + retrofit.create(HttpsUpgradeService::class.java) @Provides fun autoCompleteService(@Named("api") retrofit: Retrofit): AutoCompleteService = - retrofit.create(AutoCompleteService::class.java) + retrofit.create(AutoCompleteService::class.java) @Provides fun surrogatesService(@Named("api") retrofit: Retrofit): ResourceSurrogateListService = - retrofit.create(ResourceSurrogateListService::class.java) - - @Provides - fun feedbackService(@Named("api") retrofit: Retrofit): FeedbackService = - retrofit.create(FeedbackService::class.java) + retrofit.create(ResourceSurrogateListService::class.java) @Provides fun entityListService(@Named("api") retrofit: Retrofit): EntityListService = - retrofit.create(EntityListService::class.java) + retrofit.create(EntityListService::class.java) @Provides - fun feedbackSender(statisticsStore: StatisticsDataStore, variantManager: VariantManager, feedbackSerice: FeedbackService): FeedbackSender = - FeedbackSubmitter(statisticsStore, variantManager, feedbackSerice) + fun brokenSiteSender(statisticsStore: StatisticsDataStore, variantManager: VariantManager, feedbackService: FeedbackService): BrokenSiteSender = + BrokenSiteSubmitter(statisticsStore, variantManager, feedbackService) @Provides fun surveyService(@Named("api") retrofit: Retrofit): SurveyService = - retrofit.create(SurveyService::class.java) + retrofit.create(SurveyService::class.java) + + @Provides + fun feedbackSubmitter(feedbackService: FeedbackService, variantManager: VariantManager, apiKeyMapper: SubReasonApiMapper, statisticsStore: StatisticsDataStore, pixel: Pixel): FeedbackSubmitter = + FireAndForgetFeedbackSubmitter(feedbackService, variantManager, apiKeyMapper, statisticsStore, pixel) + + @Provides + fun feedbackService(@Named("api") retrofit: Retrofit): FeedbackService = + retrofit.create(FeedbackService::class.java) + @Provides @Singleton diff --git a/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackService.kt b/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackService.kt index b9a3cfe0264e..05ff805be60d 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackService.kt +++ b/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackService.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2019 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,34 +16,48 @@ package com.duckduckgo.app.feedback.api -import io.reactivex.Observable -import okhttp3.ResponseBody +import retrofit2.Call import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.POST -interface FeedbackService { - object Platform { - const val ANDROID = "Android" - } +interface FeedbackService { - object Reason { - const val BROKEN_SITE = "broken_site" - const val GENERAL = "general" - } + @FormUrlEncoded + @POST("/feedback.js?type=app-feedback") + fun submitFeedback( + @Field("reason") reason: String = REASON_GENERAL, + @Field("rating") rating: String, + @Field("category") category: String?, + @Field("subcategory") subcategory: String?, + @Field("comment") comment: String, + @Field("url") url: String? = null, + @Field("platform") platform: String = PLATFORM, + @Field("v") version: String, + @Field("os") api: Int, + @Field("manufacturer") manufacturer: String, + @Field("model") model: String, + @Field("atb") atb: String + ): Call @FormUrlEncoded @POST("/feedback.js?type=app-feedback") - fun feedback( - @Field("reason") reason: String, - @Field("url") url: String, + fun submitBrokenSite( + @Field("reason") reason: String = REASON_BROKEN_SITE, @Field("comment") comment: String, - @Field("platform") platform: String, + @Field("platform") platform: String = PLATFORM, + @Field("url") url: String? = null, + @Field("v") version: String, @Field("os") api: Int, @Field("manufacturer") manufacturer: String, @Field("model") model: String, - @Field("v") appVersion: String, @Field("atb") atb: String - ): Observable + ): Call + + companion object { + const val REASON_GENERAL = "general" + const val REASON_BROKEN_SITE = "broken_site" + private const val PLATFORM = "Android" + } } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSubmitter.kt b/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSubmitter.kt new file mode 100644 index 000000000000..46bf7de1af90 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSubmitter.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.api + +import android.os.Build +import com.duckduckgo.app.browser.BuildConfig +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.SubReason +import com.duckduckgo.app.statistics.VariantManager +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.FEEDBACK_NEGATIVE_SUBMISSION +import com.duckduckgo.app.statistics.store.StatisticsDataStore +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.* + + +interface FeedbackSubmitter { + + suspend fun sendNegativeFeedback(mainReason: MainReason, subReason: SubReason?, openEnded: String) + suspend fun sendPositiveFeedback(openEnded: String?) + suspend fun sendBrokenSiteFeedback(openEnded: String, brokenSite: String?) + suspend fun sendUserRated() +} + +class FireAndForgetFeedbackSubmitter( + private val feedbackService: FeedbackService, + private val variantManager: VariantManager, + private val apiKeyMapper: SubReasonApiMapper, + private val statisticsDataStore: StatisticsDataStore, + private val pixel: Pixel +) : FeedbackSubmitter { + override suspend fun sendNegativeFeedback(mainReason: MainReason, subReason: SubReason?, openEnded: String) { + Timber.i("User provided negative feedback: {$openEnded}. mainReason = $mainReason, subReason = $subReason") + + val category = categoryFromMainReason(mainReason) + val subcategory = apiKeyMapper.apiKeyFromSubReason(subReason) + + sendPixel(pixelForNegativeFeedback(category, subcategory)) + + GlobalScope.launch { + runCatching { + submitFeedback( + openEnded = openEnded, + rating = NEGATIVE_FEEDBACK, + category = category, + subcategory = subcategory + ) + } + .onSuccess { Timber.i("Successfully submitted feedback") } + .onFailure { Timber.w(it, "Failed to send feedback") } + } + } + + override suspend fun sendPositiveFeedback(openEnded: String?) { + Timber.i("User provided positive feedback: {$openEnded}") + + sendPixel(pixelForPositiveFeedback()) + + if (openEnded != null) { + GlobalScope.launch { + runCatching { submitFeedback(openEnded = openEnded, rating = POSITIVE_FEEDBACK) } + .onSuccess { Timber.i("Successfully submitted feedback") } + .onFailure { Timber.w(it, "Failed to send feedback") } + } + } + } + + override suspend fun sendBrokenSiteFeedback(openEnded: String, brokenSite: String?) { + Timber.i("User provided broken site report through feedback, url:{$brokenSite}, comment:{$openEnded}") + + val category = categoryFromMainReason(WEBSITES_NOT_LOADING) + val subcategory = apiKeyMapper.apiKeyFromSubReason(null) + sendPixel(pixelForNegativeFeedback(category, subcategory)) + + GlobalScope.launch { + runCatching { + submitFeedback( + rating = NEGATIVE_FEEDBACK, + url = brokenSite, + openEnded = openEnded, + category = category) + } + .onSuccess { Timber.i("Successfully submitted broken site feedback") } + .onFailure { Timber.w(it, "Failed to send broken site feedback") } + } + } + + override suspend fun sendUserRated() { + Timber.i("User indicated they'd rate the app") + sendPixel(pixelForPositiveFeedback()) + } + + private fun sendPixel(pixelName: String) { + Timber.d("Firing feedback pixel: $pixelName") + pixel.fire(pixelName) + } + + private fun submitFeedback( + openEnded: String, + rating: String, + category: String? = null, + subcategory: String? = null, + url: String? = null, + reason: String = FeedbackService.REASON_GENERAL + ) { + feedbackService.submitFeedback( + reason = reason, + category = category, + subcategory = subcategory, + rating = rating, + url = url, + comment = openEnded, + version = version(), + manufacturer = Build.MANUFACTURER, + model = Build.MODEL, + api = Build.VERSION.SDK_INT, + atb = atbWithVariant() + ).execute() + } + + private fun categoryFromMainReason(mainReason: MainReason): String { + return when (mainReason) { + MISSING_BROWSING_FEATURES -> "browserFeatures" + WEBSITES_NOT_LOADING -> "brokenSites" + SEARCH_NOT_GOOD_ENOUGH -> "badResults" + NOT_ENOUGH_CUSTOMIZATIONS -> "customization" + APP_IS_SLOW_OR_BUGGY -> "performance" + OTHER -> "other" + } + } + + private fun pixelForNegativeFeedback(category: String, subcategory: String): String { + return String.format(Locale.US, FEEDBACK_NEGATIVE_SUBMISSION.pixelName, NEGATIVE_FEEDBACK, category, subcategory) + } + + private fun pixelForPositiveFeedback(): String { + return String.format(Locale.US, Pixel.PixelName.FEEDBACK_POSITIVE_SUBMISSION.pixelName, POSITIVE_FEEDBACK) + } + + private fun version(): String { + return BuildConfig.VERSION_NAME + } + + private fun atbWithVariant(): String { + return statisticsDataStore.atb?.formatWithVariant(variantManager.getVariant()) ?: "" + } + + companion object { + private const val POSITIVE_FEEDBACK = "positive" + private const val NEGATIVE_FEEDBACK = "negative" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/api/SubReasonApiMapper.kt b/app/src/main/java/com/duckduckgo/app/feedback/api/SubReasonApiMapper.kt new file mode 100644 index 000000000000..ce38ffd84691 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/api/SubReasonApiMapper.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.api + +import com.duckduckgo.app.feedback.ui.negative.FeedbackType +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.CustomizationSubReasons.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MissingBrowserFeaturesSubReasons.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.PerformanceSubReasons.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.SearchNotGoodEnoughSubReasons.* +import javax.inject.Inject + + +class SubReasonApiMapper @Inject constructor() { + + fun apiKeyFromSubReason(subReason: FeedbackType.SubReason?): String { + return when (subReason) { + is FeedbackType.MissingBrowserFeaturesSubReasons -> browserFeaturesSubReason(subReason) + is FeedbackType.SearchNotGoodEnoughSubReasons -> searchNotGoodEnoughSubReason(subReason) + is FeedbackType.CustomizationSubReasons -> customizationSubReason(subReason) + is FeedbackType.PerformanceSubReasons -> performanceSubReasons(subReason) + else -> GENERIC + } + } + + private fun browserFeaturesSubReason(subReason: FeedbackType.MissingBrowserFeaturesSubReasons): String { + return when (subReason) { + NAVIGATION_ISSUES -> "navigation" + TAB_MANAGEMENT -> "tabs" + AD_POPUP_BLOCKING -> "ads" + WATCHING_VIDEOS -> "videos" + INTERACTING_IMAGES -> "images" + BOOKMARK_MANAGEMENT -> "bookmarks" + MissingBrowserFeaturesSubReasons.OTHER -> OTHER + } + } + + private fun searchNotGoodEnoughSubReason(subReason: FeedbackType.SearchNotGoodEnoughSubReasons): String { + return when (subReason) { + PROGRAMMING_TECHNICAL_SEARCHES -> "technical" + LAYOUT_MORE_LIKE_GOOGLE -> "layout" + FASTER_LOAD_TIME -> "speed" + SEARCHING_IN_SPECIFIC_LANGUAGE -> "langRegion" + BETTER_AUTOCOMPLETE -> "autocomplete" + SearchNotGoodEnoughSubReasons.OTHER -> OTHER + } + } + + private fun customizationSubReason(subReason: FeedbackType.CustomizationSubReasons): String { + return when (subReason) { + HOME_SCREEN_CONFIGURATION -> "home" + TAB_DISPLAY -> "tabs" + HOW_APP_LOOKS -> "ui" + WHICH_DATA_IS_CLEARED -> "whichDataCleared" + WHEN_DATA_IS_CLEARED -> "whenDataCleared" + BOOKMARK_DISPLAY -> "bookmarks" + CustomizationSubReasons.OTHER -> OTHER + } + } + + private fun performanceSubReasons(subReason: FeedbackType.PerformanceSubReasons): String { + return when (subReason) { + SLOW_WEB_PAGE_LOADS -> "slow" + APP_CRASHES_OR_FREEZES -> "crash" + MEDIA_PLAYBACK_BUGS -> "video" + PerformanceSubReasons.OTHER -> OTHER + } + } + + companion object { + private const val OTHER = "other" + private const val GENERIC = "submit" + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/common/FeedbackActivity.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/common/FeedbackActivity.kt new file mode 100644 index 000000000000..3e994cfe1d54 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/common/FeedbackActivity.kt @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.common + +import android.app.Activity.RESULT_CANCELED +import android.app.Activity.RESULT_OK +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.transaction +import androidx.lifecycle.Observer +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.feedback.ui.initial.InitialFeedbackFragment +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.* +import com.duckduckgo.app.feedback.ui.negative.brokensite.BrokenSiteNegativeFeedbackFragment +import com.duckduckgo.app.feedback.ui.negative.mainreason.MainReasonNegativeFeedbackFragment +import com.duckduckgo.app.feedback.ui.negative.openended.ShareOpenEndedFeedbackFragment +import com.duckduckgo.app.feedback.ui.negative.subreason.SubReasonNegativeFeedbackFragment +import com.duckduckgo.app.feedback.ui.positive.initial.PositiveFeedbackLandingFragment +import com.duckduckgo.app.global.DuckDuckGoActivity +import com.duckduckgo.app.global.view.hideKeyboard +import kotlinx.android.synthetic.main.include_toolbar.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import timber.log.Timber + + +class FeedbackActivity : DuckDuckGoActivity(), + InitialFeedbackFragment.InitialFeedbackListener, + PositiveFeedbackLandingFragment.PositiveFeedbackLandingListener, + ShareOpenEndedFeedbackFragment.OpenEndedFeedbackListener, + MainReasonNegativeFeedbackFragment.MainReasonNegativeFeedbackListener, + BrokenSiteNegativeFeedbackFragment.BrokenSiteFeedbackListener, + SubReasonNegativeFeedbackFragment.DisambiguationNegativeFeedbackListener { + + private val viewModel: FeedbackViewModel by bindViewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_feedback) + setupActionBar() + configureObservers() + } + + private fun setupActionBar() { + setSupportActionBar(toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + private fun configureObservers() { + viewModel.command.observe(this, Observer { + it?.let { command -> processCommand(command) } + }) + viewModel.updateViewCommand.observe(this, Observer { + it?.let { viewState -> render(viewState) } + }) + } + + private fun processCommand(command: Command) { + Timber.v("Processing command: $command") + + when (command) { + is Command.Exit -> animateFinish(command.feedbackSubmitted) + is Command.HideKeyboard -> hideKeyboard() + } + } + + private fun render(viewState: UpdateViewCommand) { + Timber.v("ViewState is: $viewState") + + val state = viewState.fragmentViewState + when (state) { + is FragmentState.InitialAppEnjoymentClarifier -> showInitialFeedbackView(state.forwardDirection) + is FragmentState.PositiveFeedbackFirstStep -> showPositiveFeedbackView(state.forwardDirection) + is FragmentState.PositiveShareFeedback -> showSharePositiveFeedbackView(state.forwardDirection) + is FragmentState.NegativeFeedbackMainReason -> showNegativeFeedbackMainReasonView(state.forwardDirection) + is FragmentState.NegativeFeedbackSubReason -> showNegativeFeedbackSubReasonView(state.forwardDirection, state.mainReason) + is FragmentState.NegativeOpenEndedFeedback -> showNegativeOpenEndedFeedbackView(state.forwardDirection, state.mainReason, state.subReason) + is FragmentState.NegativeWebSitesBrokenFeedback -> showNegativeWebSiteBrokenView(state.forwardDirection) + } + } + + private fun showSharePositiveFeedbackView(forwardDirection: Boolean) { + val fragment = ShareOpenEndedFeedbackFragment.instancePositiveFeedback() + updateFragment(fragment, forwardDirection) + } + + private fun showNegativeFeedbackMainReasonView(forwardDirection: Boolean) { + val fragment = MainReasonNegativeFeedbackFragment.instance() + updateFragment(fragment, forwardDirection) + } + + private fun showNegativeFeedbackSubReasonView(forwardDirection: Boolean, mainReason: MainReason) { + val fragment = SubReasonNegativeFeedbackFragment.instance(mainReason) + updateFragment(fragment, forwardDirection) + } + + private fun showNegativeOpenEndedFeedbackView(forwardDirection: Boolean, mainReason: MainReason, subReason: SubReason? = null) { + val fragment = ShareOpenEndedFeedbackFragment.instanceNegativeFeedback(mainReason, subReason) + updateFragment(fragment, forwardDirection) + } + + private fun showInitialFeedbackView(forwardDirection: Boolean) { + val fragment = InitialFeedbackFragment.instance() + updateFragment(fragment, forwardDirection) + } + + private fun showPositiveFeedbackView(forwardDirection: Boolean) { + val fragment = PositiveFeedbackLandingFragment.instance() + updateFragment(fragment, forwardDirection) + } + + private fun showNegativeWebSiteBrokenView(forwardDirection: Boolean) { + val fragment = BrokenSiteNegativeFeedbackFragment.instance() + updateFragment(fragment, forwardDirection) + } + + private fun updateFragment(fragment: FeedbackFragment, forwardDirection: Boolean) { + val tag = fragment.javaClass.name + if (supportFragmentManager.findFragmentByTag(tag) != null) return + + supportFragmentManager.transaction { + this.applyTransition(forwardDirection) + replace(R.id.fragmentContainer, fragment, fragment.tag) + } + } + + override fun onBackPressed() { + viewModel.onBackPressed() + } + + /** + * Initial feedback page listeners + */ + override fun userSelectedPositiveFeedback() { + viewModel.userSelectedPositiveFeedback() + } + + override fun userSelectedNegativeFeedback() { + viewModel.userSelectedNegativeFeedback() + } + + override fun userCancelled() { + viewModel.userWantsToCancel() + } + + /** + * Positive feedback listeners + */ + override fun userSelectedToRateApp() { + GlobalScope.launch(Dispatchers.Main) { viewModel.userSelectedToRateApp() } + } + + override fun userSelectedToGiveFeedback() { + viewModel.userSelectedToGiveFeedback() + } + + override fun userGavePositiveFeedbackNoDetails() { + GlobalScope.launch(Dispatchers.Main) { viewModel.userGavePositiveFeedbackNoDetails() } + } + + override fun userProvidedPositiveOpenEndedFeedback(feedback: String) { + GlobalScope.launch(Dispatchers.Main) { viewModel.userProvidedPositiveOpenEndedFeedback(feedback) } + } + + /** + * Negative feedback listeners + */ + override fun userProvidedNegativeOpenEndedFeedback(mainReason: MainReason, subReason: SubReason?, feedback: String) { + GlobalScope.launch(Dispatchers.Main) { viewModel.userProvidedNegativeOpenEndedFeedback(mainReason, subReason, feedback) } + } + + /** + * Negative feedback main reason selection + */ + override fun userSelectedNegativeFeedbackMainReason(type: MainReason) { + viewModel.userSelectedNegativeFeedbackMainReason(type) + } + + /** + * Negative feedback subReason selection + */ + + override fun userSelectedSubReasonMissingBrowserFeatures(mainReason: MainReason, subReason: MissingBrowserFeaturesSubReasons) { + viewModel.userSelectedSubReasonMissingBrowserFeatures(mainReason, subReason) + } + + override fun userSelectedSubReasonSearchNotGoodEnough(mainReason: MainReason, subReason: SearchNotGoodEnoughSubReasons) { + viewModel.userSelectedSubReasonSearchNotGoodEnough(mainReason, subReason) + } + + override fun userSelectedSubReasonNeedMoreCustomization(mainReason: MainReason, subReason: CustomizationSubReasons) { + viewModel.userSelectedSubReasonNeedMoreCustomization(mainReason, subReason) + } + + override fun userSelectedSubReasonAppIsSlowOrBuggy(mainReason: MainReason, subReason: PerformanceSubReasons) { + viewModel.userSelectedSubReasonAppIsSlowOrBuggy(mainReason, subReason) + } + + /** + * Negative feedback, broken site + */ + override fun onProvidedBrokenSiteFeedback(feedback: String, url: String?) { + GlobalScope.launch(Dispatchers.Main) { viewModel.onProvidedBrokenSiteFeedback(feedback, url) } + } + + private fun hideKeyboard() { + toolbar?.hideKeyboard() + } + + companion object { + fun intent(context: Context): Intent { + return Intent(context, FeedbackActivity::class.java) + } + } +} + +private fun FeedbackActivity.animateFinish(feedbackSubmitted: Boolean) { + val resultCode = + if (feedbackSubmitted) { + RESULT_OK + } else { + RESULT_CANCELED + } + setResult(resultCode) + + finish() + overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right) +} + +private fun FragmentTransaction.applyTransition(forwardDirection: Boolean) { + if (forwardDirection) { + setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left) + } else { + setCustomAnimations(R.anim.slide_from_left, R.anim.slide_to_right) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/common/FeedbackFragment.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/common/FeedbackFragment.kt new file mode 100644 index 000000000000..d61fb719142a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/common/FeedbackFragment.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.common + +import android.content.Context +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProviders +import com.duckduckgo.app.global.ViewModelFactory +import dagger.android.support.AndroidSupportInjection +import javax.inject.Inject + + +abstract class FeedbackFragment : Fragment() { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + override fun onAttach(context: Context) { + AndroidSupportInjection.inject(this) + super.onAttach(context) + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + configureListeners() + configureViewModelObservers() + } + + open fun configureViewModelObservers() {} + open fun configureListeners() {} + + protected inline fun bindViewModel() = lazy { ViewModelProviders.of(this, viewModelFactory).get(V::class.java) } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/common/FeedbackItemDecoration.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/common/FeedbackItemDecoration.kt new file mode 100644 index 000000000000..8a6ea89cf101 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/common/FeedbackItemDecoration.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.common + +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import androidx.recyclerview.widget.RecyclerView +import com.duckduckgo.app.global.view.toPx + +class FeedbackItemDecoration(private val divider: Drawable) : RecyclerView.ItemDecoration() { + + override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val childCount = parent.childCount + val parentRight = parent.width - parent.paddingRight + + for (i in 0 until childCount) { + val child = parent.getChildAt(i) + val params = child.layoutParams as MarginLayoutParams + + drawBottomDivider(canvas, parent, child, params, i, parentRight) + + if (isFirstItem(i)) { + drawTopDivider(canvas, child, params, parentRight) + } + } + } + + private fun drawTopDivider(canvas: Canvas, child: View, params: MarginLayoutParams, parentRight: Int) { + val horizontalSize = horizontalSizeForFullWidthDivider(parentRight) + val verticalSize = verticalSizeForTopDivider(child, params) + drawDivider(canvas, horizontalSize, verticalSize) + } + + private fun drawBottomDivider(canvas: Canvas, parent: RecyclerView, child: View, params: MarginLayoutParams, i: Int, parentRight: Int) { + val verticalSize = verticalSizeForBottomDivider(child, params) + val horizontalSize = determineDividerWidth(i, parent, parentRight) + drawDivider(canvas, horizontalSize, verticalSize) + } + + private fun determineDividerWidth(i: Int, parent: RecyclerView, parentRight: Int): HorizontalSize { + return if (isLastItem(i, parent)) { + horizontalSizeForFullWidthDivider(parentRight) + } else { + horizontalSizeForPartialWidthDivider(parent, parentRight) + } + } + + private fun drawDivider(canvas: Canvas, horizontalSize: HorizontalSize, verticalSize: VerticalSize) { + divider.setBounds(horizontalSize.left, verticalSize.top, horizontalSize.right, verticalSize.bottom) + divider.draw(canvas) + } + + private fun horizontalSizeForFullWidthDivider(right: Int): HorizontalSize = HorizontalSize(left = 0, right = right) + + private fun horizontalSizeForPartialWidthDivider(parent: RecyclerView, right: Int): HorizontalSize { + return HorizontalSize(left = parent.paddingLeft + INDENTATION_SIZE_DP.toPx(), right = right) + } + + private fun verticalSizeForTopDivider(child: View, params: MarginLayoutParams): VerticalSize { + val top = child.top + params.topMargin + val bottom = top + divider.intrinsicHeight + return VerticalSize(top = top, bottom = bottom) + } + + private fun verticalSizeForBottomDivider(child: View, params: MarginLayoutParams): VerticalSize { + val top = child.bottom + params.bottomMargin + val bottom = top + divider.intrinsicHeight + return VerticalSize(top = top, bottom = bottom) + } + + private data class HorizontalSize(val left: Int, val right: Int) + + private data class VerticalSize(val top: Int, val bottom: Int) + + private fun isFirstItem(position: Int) = position == 0 + + private fun isLastItem(position: Int, parent: RecyclerView) = (position + 1) == parent.totalItemCount() + + private fun RecyclerView.totalItemCount() = adapter?.itemCount + + companion object { + private const val INDENTATION_SIZE_DP = 22 + } +} diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/common/FeedbackViewModel.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/common/FeedbackViewModel.kt new file mode 100644 index 000000000000..3e8cbf753687 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/common/FeedbackViewModel.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.common + +import androidx.lifecycle.ViewModel +import com.duckduckgo.app.browser.BuildConfig +import com.duckduckgo.app.feedback.api.FeedbackSubmitter +import com.duckduckgo.app.feedback.ui.common.Command.Exit +import com.duckduckgo.app.feedback.ui.common.FragmentState.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.SubReason +import com.duckduckgo.app.global.SingleLiveEvent +import com.duckduckgo.app.playstore.PlayStoreUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + + +class FeedbackViewModel(private val playStoreUtils: PlayStoreUtils, private val feedbackSubmitter: FeedbackSubmitter) : ViewModel() { + + val command: SingleLiveEvent = SingleLiveEvent() + val updateViewCommand: SingleLiveEvent = SingleLiveEvent() + + init { + updateViewCommand.postValue(UpdateViewCommand(fragmentViewState = InitialAppEnjoymentClarifier(NAVIGATION_FORWARDS))) + } + + private val currentViewState: UpdateViewCommand + get() = updateViewCommand.value!! + + + fun userSelectedNegativeFeedbackMainReason(mainReason: MainReason) { + val newState = when (mainReason) { + MainReason.MISSING_BROWSING_FEATURES -> NegativeFeedbackSubReason(NAVIGATION_FORWARDS, mainReason) + MainReason.WEBSITES_NOT_LOADING -> NegativeWebSitesBrokenFeedback(NAVIGATION_FORWARDS, mainReason) + MainReason.SEARCH_NOT_GOOD_ENOUGH -> NegativeFeedbackSubReason(NAVIGATION_FORWARDS, mainReason) + MainReason.NOT_ENOUGH_CUSTOMIZATIONS -> NegativeFeedbackSubReason(NAVIGATION_FORWARDS, mainReason) + MainReason.APP_IS_SLOW_OR_BUGGY -> NegativeFeedbackSubReason(NAVIGATION_FORWARDS, mainReason) + MainReason.OTHER -> NegativeOpenEndedFeedback(NAVIGATION_FORWARDS, mainReason) + } + updateViewCommand.value = currentViewState.copy( + fragmentViewState = newState, + mainReason = mainReason, + subReason = null, + previousViewState = currentViewState.fragmentViewState + ) + } + + fun onBackPressed() { + var shouldHideKeyboard = false + when (currentViewState.fragmentViewState) { + is InitialAppEnjoymentClarifier -> { + command.value = Exit(feedbackSubmitted = false) + } + is PositiveFeedbackFirstStep -> { + updateViewCommand.value = currentViewState.copy(fragmentViewState = InitialAppEnjoymentClarifier(NAVIGATION_BACKWARDS)) + } + is PositiveShareFeedback -> { + shouldHideKeyboard = true + if (canShowRatingsButton()) { + updateViewCommand.value = currentViewState.copy(fragmentViewState = PositiveFeedbackFirstStep(NAVIGATION_BACKWARDS)) + } else { + updateViewCommand.value = currentViewState.copy(fragmentViewState = InitialAppEnjoymentClarifier(NAVIGATION_BACKWARDS)) + } + } + is NegativeFeedbackMainReason -> { + updateViewCommand.value = currentViewState.copy(fragmentViewState = InitialAppEnjoymentClarifier(NAVIGATION_BACKWARDS)) + } + is NegativeFeedbackSubReason -> { + updateViewCommand.value = currentViewState.copy(fragmentViewState = NegativeFeedbackMainReason(NAVIGATION_BACKWARDS)) + } + is NegativeWebSitesBrokenFeedback -> { + shouldHideKeyboard = true + updateViewCommand.value = currentViewState.copy(fragmentViewState = NegativeFeedbackMainReason(NAVIGATION_BACKWARDS)) + } + is NegativeOpenEndedFeedback -> { + shouldHideKeyboard = true + val newViewState = when (currentViewState.previousViewState) { + is NegativeFeedbackSubReason -> { + NegativeFeedbackSubReason(NAVIGATION_BACKWARDS, currentViewState.mainReason!!) + } + is NegativeFeedbackMainReason -> { + NegativeFeedbackMainReason(NAVIGATION_BACKWARDS) + } + else -> { + NegativeFeedbackMainReason(NAVIGATION_BACKWARDS) + } + } + updateViewCommand.value = currentViewState.copy(fragmentViewState = newViewState) + } + } + + if (shouldHideKeyboard) { + command.value = Command.HideKeyboard + } + } + + fun userSelectedPositiveFeedback() { + updateViewCommand.value = if (canShowRatingsButton()) { + currentViewState.copy( + fragmentViewState = PositiveFeedbackFirstStep(NAVIGATION_FORWARDS), + previousViewState = currentViewState.fragmentViewState + ) + } else { + currentViewState.copy( + fragmentViewState = PositiveShareFeedback(NAVIGATION_FORWARDS), + previousViewState = currentViewState.fragmentViewState + ) + } + } + + private fun canShowRatingsButton(): Boolean { + val playStoreInstalled = playStoreUtils.isPlayStoreInstalled() + + if (!playStoreInstalled) { + Timber.i("Play Store not installed") + return false + } + + if (playStoreUtils.installedFromPlayStore()) { + return true + } + + if (BuildConfig.DEBUG) { + Timber.i("Not installed from the Play Store but it is DEBUG; will treat as if installed from Play Store") + return true + } + return false + } + + fun userSelectedNegativeFeedback() { + updateViewCommand.value = currentViewState.copy( + fragmentViewState = NegativeFeedbackMainReason(NAVIGATION_FORWARDS), + previousViewState = currentViewState.fragmentViewState + ) + } + + fun userWantsToCancel() { + command.value = Exit(feedbackSubmitted = false) + } + + fun userSelectedToGiveFeedback() { + updateViewCommand.value = currentViewState.copy( + fragmentViewState = PositiveShareFeedback(NAVIGATION_FORWARDS), + previousViewState = currentViewState.fragmentViewState + ) + } + + suspend fun userProvidedNegativeOpenEndedFeedback(mainReason: MainReason, subReason: SubReason?, feedback: String) { + command.value = Exit(feedbackSubmitted = true) + withContext(Dispatchers.IO) { + feedbackSubmitter.sendNegativeFeedback(mainReason, subReason, feedback) + } + } + + suspend fun onProvidedBrokenSiteFeedback(feedback: String, brokenSite: String?) { + command.value = Exit(feedbackSubmitted = true) + withContext(Dispatchers.IO) { + feedbackSubmitter.sendBrokenSiteFeedback(feedback, brokenSite) + } + } + + suspend fun userGavePositiveFeedbackNoDetails() { + command.value = Exit(feedbackSubmitted = true) + withContext(Dispatchers.IO) { + feedbackSubmitter.sendPositiveFeedback(null) + } + } + + suspend fun userSelectedToRateApp() { + command.value = Exit(feedbackSubmitted = true) + GlobalScope.launch(Dispatchers.IO) { + feedbackSubmitter.sendUserRated() + } + } + + suspend fun userProvidedPositiveOpenEndedFeedback(feedback: String) { + command.value = Exit(feedbackSubmitted = true) + withContext(Dispatchers.IO) { + feedbackSubmitter.sendPositiveFeedback(feedback) + } + } + + fun userSelectedSubReasonMissingBrowserFeatures(mainReason: MainReason, subReason: FeedbackType.MissingBrowserFeaturesSubReasons) { + val newState = NegativeOpenEndedFeedback(NAVIGATION_FORWARDS, mainReason, subReason) + updateViewCommand.value = currentViewState.copy( + fragmentViewState = newState, + mainReason = mainReason, + subReason = subReason, + previousViewState = currentViewState.fragmentViewState + ) + } + + fun userSelectedSubReasonSearchNotGoodEnough(mainReason: MainReason, subReason: FeedbackType.SearchNotGoodEnoughSubReasons) { + val newState = NegativeOpenEndedFeedback(NAVIGATION_FORWARDS, mainReason, subReason) + updateViewCommand.value = currentViewState.copy( + fragmentViewState = newState, + mainReason = mainReason, + subReason = subReason, + previousViewState = currentViewState.fragmentViewState + ) + } + + fun userSelectedSubReasonNeedMoreCustomization(mainReason: MainReason, subReason: FeedbackType.CustomizationSubReasons) { + val newState = NegativeOpenEndedFeedback(NAVIGATION_FORWARDS, mainReason, subReason) + updateViewCommand.value = currentViewState.copy( + fragmentViewState = newState, + mainReason = mainReason, + subReason = subReason, + previousViewState = currentViewState.fragmentViewState + ) + } + + fun userSelectedSubReasonAppIsSlowOrBuggy(mainReason: MainReason, subReason: FeedbackType.PerformanceSubReasons) { + val newState = NegativeOpenEndedFeedback(NAVIGATION_FORWARDS, mainReason, subReason) + updateViewCommand.value = currentViewState.copy( + fragmentViewState = newState, + mainReason = mainReason, + subReason = subReason, + previousViewState = currentViewState.fragmentViewState + ) + } + + companion object { + const val NAVIGATION_FORWARDS = true + const val NAVIGATION_BACKWARDS = false + } +} + +data class ViewState( + val fragmentViewState: FragmentState, + val previousViewState: FragmentState? = null, + val mainReason: MainReason? = null, + val subReason: SubReason? = null +) + +sealed class FragmentState(open val forwardDirection: Boolean) { + data class InitialAppEnjoymentClarifier(override val forwardDirection: Boolean) : FragmentState(forwardDirection) + + // positive flow + data class PositiveFeedbackFirstStep(override val forwardDirection: Boolean) : FragmentState(forwardDirection) + + data class PositiveShareFeedback(override val forwardDirection: Boolean) : FragmentState(forwardDirection) + + // negative flow + data class NegativeFeedbackMainReason(override val forwardDirection: Boolean) : FragmentState(forwardDirection) + + data class NegativeFeedbackSubReason(override val forwardDirection: Boolean, val mainReason: MainReason) : FragmentState(forwardDirection) + data class NegativeOpenEndedFeedback(override val forwardDirection: Boolean, val mainReason: MainReason, val subReason: SubReason? = null) : + FragmentState(forwardDirection) + + data class NegativeWebSitesBrokenFeedback( + override val forwardDirection: Boolean, + val mainReason: MainReason, + val subReason: SubReason? = null + ) : FragmentState(forwardDirection) +} + +sealed class Command { + data class Exit(val feedbackSubmitted: Boolean) : Command() + object HideKeyboard : Command() +} + +data class UpdateViewCommand( + val fragmentViewState: FragmentState, + val previousViewState: FragmentState? = null, + val mainReason: MainReason? = null, + val subReason: SubReason? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/common/LayoutScrollingTouchListener.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/common/LayoutScrollingTouchListener.kt new file mode 100644 index 000000000000..1ba4d5a5fd26 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/common/LayoutScrollingTouchListener.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.common + +import android.view.MotionEvent +import android.view.View +import android.widget.ScrollView + + +class LayoutScrollingTouchListener(private val scrollView: ScrollView, private val desiredScrollPosition: Int) : View.OnTouchListener { + + override fun onTouch(v: View?, event: MotionEvent?): Boolean { + if (event?.action == MotionEvent.ACTION_UP) { + v?.performClick() + scrollView.postDelayed({ scrollView.smoothScrollTo(0, desiredScrollPosition) }, POST_DELAY_MS) + } + return false + } + + companion object { + private const val POST_DELAY_MS = 300L + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/initial/InitialFeedbackFragment.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/initial/InitialFeedbackFragment.kt new file mode 100644 index 000000000000..1212cc219e15 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/initial/InitialFeedbackFragment.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.initial + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.Observer +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.feedback.ui.common.FeedbackFragment +import com.duckduckgo.app.feedback.ui.initial.InitialFeedbackFragmentViewModel.Command.* +import com.duckduckgo.app.global.DuckDuckGoTheme +import com.duckduckgo.app.settings.db.SettingsDataStore +import kotlinx.android.synthetic.main.content_feedback.* +import javax.inject.Inject + + +class InitialFeedbackFragment : FeedbackFragment() { + + @Inject + lateinit var settingsDataStore: SettingsDataStore + + interface InitialFeedbackListener { + fun userSelectedPositiveFeedback() + fun userSelectedNegativeFeedback() + fun userCancelled() + } + + private val viewModel by bindViewModel() + + private val listener: InitialFeedbackListener? + get() = activity as InitialFeedbackListener + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.content_feedback, container, false) + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + if (settingsDataStore.theme == DuckDuckGoTheme.LIGHT) { + positiveFeedbackButton.setImageResource(R.drawable.button_happy_light_theme) + negativeFeedbackButton.setImageResource(R.drawable.button_sad_light_theme) + } else { + positiveFeedbackButton.setImageResource(R.drawable.button_happy_dark_theme) + negativeFeedbackButton.setImageResource(R.drawable.button_sad_dark_theme) + } + } + + override fun configureViewModelObservers() { + viewModel.command.observe(this, Observer { + when (it) { + PositiveFeedbackSelected -> listener?.userSelectedPositiveFeedback() + NegativeFeedbackSelected -> listener?.userSelectedNegativeFeedback() + UserCancelled -> listener?.userCancelled() + } + }) + } + + override fun configureListeners() { + positiveFeedbackButton.setOnClickListener { viewModel.onPositiveFeedback() } + negativeFeedbackButton.setOnClickListener { viewModel.onNegativeFeedback() } + } + + companion object { + fun instance(): InitialFeedbackFragment { + return InitialFeedbackFragment() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/initial/InitialFeedbackFragmentViewModel.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/initial/InitialFeedbackFragmentViewModel.kt new file mode 100644 index 000000000000..39f4084ee2ed --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/initial/InitialFeedbackFragmentViewModel.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.initial + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.duckduckgo.app.feedback.ui.common.ViewState + +class InitialFeedbackFragmentViewModel : ViewModel() { + + val viewState: MutableLiveData = MutableLiveData() + val command: MutableLiveData = MutableLiveData() + + fun onPositiveFeedback() { + command.value = Command.PositiveFeedbackSelected + } + + fun onNegativeFeedback() { + command.value = Command.NegativeFeedbackSelected + } + + sealed class Command { + object PositiveFeedbackSelected : Command() + object NegativeFeedbackSelected : Command() + object UserCancelled : Command() + } +} diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/FeedbackType.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/FeedbackType.kt new file mode 100644 index 000000000000..031043ca6aa0 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/FeedbackType.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.negative + +import java.io.Serializable + +sealed class FeedbackType { + + enum class MainReason { + MISSING_BROWSING_FEATURES, + WEBSITES_NOT_LOADING, + SEARCH_NOT_GOOD_ENOUGH, + NOT_ENOUGH_CUSTOMIZATIONS, + APP_IS_SLOW_OR_BUGGY, + OTHER + } + + interface SubReason : Serializable + + enum class MissingBrowserFeaturesSubReasons : SubReason { + NAVIGATION_ISSUES, + TAB_MANAGEMENT, + AD_POPUP_BLOCKING, + WATCHING_VIDEOS, + INTERACTING_IMAGES, + BOOKMARK_MANAGEMENT, + OTHER + } + + enum class SearchNotGoodEnoughSubReasons : SubReason { + PROGRAMMING_TECHNICAL_SEARCHES, + LAYOUT_MORE_LIKE_GOOGLE, + FASTER_LOAD_TIME, + SEARCHING_IN_SPECIFIC_LANGUAGE, + BETTER_AUTOCOMPLETE, + OTHER + } + + enum class CustomizationSubReasons : SubReason { + HOME_SCREEN_CONFIGURATION, + TAB_DISPLAY, + HOW_APP_LOOKS, + WHICH_DATA_IS_CLEARED, + WHEN_DATA_IS_CLEARED, + BOOKMARK_DISPLAY, + OTHER + } + + enum class PerformanceSubReasons : SubReason { + SLOW_WEB_PAGE_LOADS, + APP_CRASHES_OR_FREEZES, + MEDIA_PLAYBACK_BUGS, + OTHER + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/FeedbackTypeDisplay.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/FeedbackTypeDisplay.kt new file mode 100644 index 000000000000..ef7d5823708e --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/FeedbackTypeDisplay.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.negative + +import androidx.annotation.StringRes +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.CustomizationSubReasons.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason.OTHER +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MissingBrowserFeaturesSubReasons.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.PerformanceSubReasons.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.SearchNotGoodEnoughSubReasons.* + +fun MainReason.displayText(): FeedbackTypeDisplay.FeedbackTypeMainReasonDisplay? = FeedbackTypeDisplay.mainReasons[this] + +class FeedbackTypeDisplay { + + data class FeedbackTypeMainReasonDisplay( + val mainReason: MainReason, + + @StringRes + val listDisplayResId: Int, + + @StringRes + val titleDisplayResId: Int, + + @StringRes + val subtitleDisplayResId: Int + ) + + data class FeedbackTypeSubReasonDisplay( + val subReason: SubReason, + + @StringRes + val listDisplayResId: Int, + + @StringRes + val subtitleDisplayResId: Int = listDisplayResId + ) + + companion object { + val mainReasons: Map = mutableMapOf().also { + + MISSING_BROWSING_FEATURES.also { type -> + it[type] = FeedbackTypeMainReasonDisplay( + type, + listDisplayResId = R.string.missingBrowserFeaturesTitleLong, + titleDisplayResId = R.string.missingBrowserFeaturesTitleShort, + subtitleDisplayResId = R.string.missingBrowserFeaturesSubtitle + ) + } + + WEBSITES_NOT_LOADING.also { type -> + it[type] = FeedbackTypeMainReasonDisplay( + type, + R.string.websiteNotLoadingTitleShort, + R.string.websiteNotLoadingTitleLong, + R.string.websiteNotLoadingSubtitle + ) + } + + SEARCH_NOT_GOOD_ENOUGH.also { type -> + it[type] = FeedbackTypeMainReasonDisplay( + type, + R.string.searchNotGoodEnoughTitleLong, + R.string.searchNotGoodEnoughTitleShort, + R.string.searchNotGoodEnoughSubtitle + ) + } + + NOT_ENOUGH_CUSTOMIZATIONS.also { type -> + it[type] = FeedbackTypeMainReasonDisplay( + type, + R.string.needMoreCustomizationTitleLong, + R.string.needMoreCustomizationTitleShort, + R.string.needMoreCustomizationSubtitle + ) + } + + APP_IS_SLOW_OR_BUGGY.also { type -> + it[type] = FeedbackTypeMainReasonDisplay( + type, + R.string.appIsSlowOrBuggyTitleLong, + R.string.appIsSlowOrBuggyTitleShort, + R.string.appIsSlowOrBuggySubtitle + ) + } + + OTHER.also { type -> + it[type] = FeedbackTypeMainReasonDisplay( + type, + R.string.otherMainReasonTitleLong, + R.string.otherMainReasonTitleShort, + R.string.otherMainReasonSubtitle + ) + } + } + + val subReasons: Map = mutableMapOf().also { + + NAVIGATION_ISSUES.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.missingBrowserFeatureSubReasonNavigation) + } + + TAB_MANAGEMENT.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.missingBrowserFeatureSubReasonTabManagement) + } + + AD_POPUP_BLOCKING.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.missingBrowserFeatureSubReasonAdPopups) + } + + WATCHING_VIDEOS.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.missingBrowserFeatureSubReasonVideos) + } + + INTERACTING_IMAGES.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.missingBrowserFeatureSubReasonImages) + } + + BOOKMARK_MANAGEMENT.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.missingBrowserFeatureSubReasonBookmarks) + } + + MissingBrowserFeaturesSubReasons.OTHER.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.missingBrowserFeatureSubReasonOther, R.string.tellUsHowToImprove) + } + + PROGRAMMING_TECHNICAL_SEARCHES.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.searchNotGoodEnoughSubReasonTechnicalSearches) + } + + LAYOUT_MORE_LIKE_GOOGLE.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.searchNotGoodEnoughSubReasonGoogleLayout) + } + + FASTER_LOAD_TIME.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.searchNotGoodEnoughSubReasonFasterLoadTimes) + } + + BETTER_AUTOCOMPLETE.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.searchNotGoodEnoughSubReasonBetterAutocomplete) + } + + SEARCHING_IN_SPECIFIC_LANGUAGE.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.searchNotGoodEnoughSubReasonSpecificLanguage) + } + + SearchNotGoodEnoughSubReasons.OTHER.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.searchNotGoodEnoughSubReasonOther, R.string.tellUsHowToImprove) + } + + HOME_SCREEN_CONFIGURATION.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.needMoreCustomizationSubReasonHomeScreenConfiguration) + } + + TAB_DISPLAY.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.needMoreCustomizationSubReasonTabDisplay) + } + + HOW_APP_LOOKS.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.needMoreCustomizationSubReasonAppLooks) + } + + WHICH_DATA_IS_CLEARED.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.needMoreCustomizationSubReasonWhichDataIsCleared) + } + + WHEN_DATA_IS_CLEARED.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.needMoreCustomizationSubReasonWhenDataIsCleared) + } + + BOOKMARK_DISPLAY.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.needMoreCustomizationSubReasonBookmarksDisplay) + } + + CustomizationSubReasons.OTHER.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.needMoreCustomizationSubReasonOther, R.string.tellUsHowToImprove) + } + + SLOW_WEB_PAGE_LOADS.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.appIsSlowOrBuggySubReasonSlowResults) + } + + APP_CRASHES_OR_FREEZES.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.appIsSlowOrBuggySubReasonAppCrashesOrFreezes) + } + + MEDIA_PLAYBACK_BUGS.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.appIsSlowOrBuggySubReasonMediaPlayback) + } + + PerformanceSubReasons.OTHER.also { type -> + it[type] = FeedbackTypeSubReasonDisplay(type, R.string.appIsSlowOrBuggySubReasonOther, R.string.tellUsHowToImprove) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/brokensite/BrokenSiteNegativeFeedbackFragment.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/brokensite/BrokenSiteNegativeFeedbackFragment.kt new file mode 100644 index 000000000000..4406678865a5 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/brokensite/BrokenSiteNegativeFeedbackFragment.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.negative.brokensite + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.doOnNextLayout +import androidx.lifecycle.Observer +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.feedback.ui.common.FeedbackFragment +import com.duckduckgo.app.feedback.ui.common.LayoutScrollingTouchListener +import kotlinx.android.synthetic.main.content_feedback_negative_broken_site_feedback.* + + +class BrokenSiteNegativeFeedbackFragment : FeedbackFragment() { + + interface BrokenSiteFeedbackListener { + fun onProvidedBrokenSiteFeedback(feedback: String, url: String?) + fun userCancelled() + } + + private val viewModel by bindViewModel() + + private val listener: BrokenSiteFeedbackListener? + get() = activity as BrokenSiteFeedbackListener + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.content_feedback_negative_broken_site_feedback, container, false) + } + + override fun configureViewModelObservers() { + viewModel.command.observe(this, Observer { command -> + when (command) { + is BrokenSiteNegativeFeedbackViewModel.Command.Exit -> { + listener?.userCancelled() + } + is BrokenSiteNegativeFeedbackViewModel.Command.ExitAndSubmitFeedback -> { + listener?.onProvidedBrokenSiteFeedback(command.feedback, command.brokenSite) + } + } + }) + } + + override fun configureListeners() { + submitFeedbackButton.doOnNextLayout { + brokenSiteInput.setOnTouchListener(LayoutScrollingTouchListener(rootScrollView, brokenSiteInputContainer.y.toInt())) + openEndedFeedback.setOnTouchListener(LayoutScrollingTouchListener(rootScrollView, openEndedFeedbackContainer.y.toInt())) + } + + submitFeedbackButton.setOnClickListener { + val feedback = openEndedFeedback.text.toString() + val brokenSite = brokenSiteInput.text.toString() + + viewModel.userSubmittingFeedback(feedback, brokenSite) + } + } + + companion object { + + fun instance(): BrokenSiteNegativeFeedbackFragment { + return BrokenSiteNegativeFeedbackFragment() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/brokensite/BrokenSiteNegativeFeedbackViewModel.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/brokensite/BrokenSiteNegativeFeedbackViewModel.kt new file mode 100644 index 000000000000..e603100be98f --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/brokensite/BrokenSiteNegativeFeedbackViewModel.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.negative.brokensite + +import androidx.lifecycle.ViewModel +import com.duckduckgo.app.global.SingleLiveEvent + + +class BrokenSiteNegativeFeedbackViewModel : ViewModel() { + + val command: SingleLiveEvent = SingleLiveEvent() + + fun userSubmittingFeedback(feedback: String, brokenSite: String?) { + command.value = Command.ExitAndSubmitFeedback(feedback, brokenSite) + } + + sealed class Command { + data class ExitAndSubmitFeedback(val feedback: String, val brokenSite: String?) : Command() + object Exit : Command() + } +} + diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/mainreason/MainReasonAdapter.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/mainreason/MainReasonAdapter.kt new file mode 100644 index 000000000000..44aaac1e31d1 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/mainreason/MainReasonAdapter.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.negative.mainreason + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.feedback.ui.negative.FeedbackTypeDisplay.FeedbackTypeMainReasonDisplay +import kotlinx.android.synthetic.main.item_feedback_reason.view.* + + +class MainReasonAdapter(private val itemClickListener: (FeedbackTypeMainReasonDisplay) -> Unit) : + ListAdapter(DiffCallback()) { + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FeedbackTypeMainReasonDisplay, newItem: FeedbackTypeMainReasonDisplay): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: FeedbackTypeMainReasonDisplay, newItem: FeedbackTypeMainReasonDisplay): Boolean { + return oldItem == newItem + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val root = inflater.inflate(R.layout.item_feedback_reason, parent, false) + return ViewHolder(root, root.reason) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position), itemClickListener) + } + + data class ViewHolder(val root: View, val reasonTextView: TextView) : RecyclerView.ViewHolder(root) { + + fun bind(reason: FeedbackTypeMainReasonDisplay, clickListener: (FeedbackTypeMainReasonDisplay) -> Unit) { + reasonTextView.text = root.context.getString(reason.listDisplayResId) + itemView.setOnClickListener { clickListener(reason) } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/mainreason/MainReasonNegativeFeedbackFragment.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/mainreason/MainReasonNegativeFeedbackFragment.kt new file mode 100644 index 000000000000..6c3859815369 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/mainreason/MainReasonNegativeFeedbackFragment.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.negative.mainreason + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.LinearLayoutManager +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.feedback.ui.common.FeedbackFragment +import com.duckduckgo.app.feedback.ui.common.FeedbackItemDecoration +import com.duckduckgo.app.feedback.ui.negative.FeedbackType +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason +import com.duckduckgo.app.feedback.ui.negative.FeedbackTypeDisplay +import com.duckduckgo.app.feedback.ui.negative.FeedbackTypeDisplay.FeedbackTypeMainReasonDisplay +import kotlinx.android.synthetic.main.content_feedback_negative_disambiguation_main_reason.* + + +class MainReasonNegativeFeedbackFragment : FeedbackFragment() { + private lateinit var recyclerAdapter: MainReasonAdapter + + interface MainReasonNegativeFeedbackListener { + + fun userSelectedNegativeFeedbackMainReason(type: MainReason) + } + private val listener: MainReasonNegativeFeedbackListener? + get() = activity as MainReasonNegativeFeedbackListener + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val rootView = inflater.inflate(R.layout.content_feedback_negative_disambiguation_main_reason, container, false) + + recyclerAdapter = MainReasonAdapter(object : (FeedbackTypeMainReasonDisplay) -> Unit { + override fun invoke(reason: FeedbackTypeMainReasonDisplay) { + listener?.userSelectedNegativeFeedbackMainReason(reason.mainReason) + } + }) + + return rootView + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + activity?.let { + recyclerView.layoutManager = LinearLayoutManager(it) + recyclerView.adapter = recyclerAdapter + recyclerView.addItemDecoration(FeedbackItemDecoration(ContextCompat.getDrawable(it, R.drawable.feedback_list_divider)!!)) + + val listValues = getMainReasonsDisplayText() + recyclerAdapter.submitList(listValues) + } + } + + + private fun getMainReasonsDisplayText(): List { + return FeedbackType.MainReason.values().mapNotNull { + FeedbackTypeDisplay.mainReasons[it] + } + } + + companion object { + + fun instance(): MainReasonNegativeFeedbackFragment { + return MainReasonNegativeFeedbackFragment() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/openended/ShareOpenEndedFeedbackFragment.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/openended/ShareOpenEndedFeedbackFragment.kt new file mode 100644 index 000000000000..2b4c93e9c347 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/openended/ShareOpenEndedFeedbackFragment.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.negative.openended + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.doOnNextLayout +import androidx.lifecycle.Observer +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.feedback.ui.common.FeedbackFragment +import com.duckduckgo.app.feedback.ui.common.LayoutScrollingTouchListener +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.SubReason +import com.duckduckgo.app.feedback.ui.negative.FeedbackTypeDisplay.Companion.mainReasons +import com.duckduckgo.app.feedback.ui.negative.FeedbackTypeDisplay.Companion.subReasons +import com.duckduckgo.app.feedback.ui.negative.openended.ShareOpenEndedNegativeFeedbackViewModel.Command +import kotlinx.android.synthetic.main.content_feedback_open_ended_feedback.* + + +class ShareOpenEndedFeedbackFragment : FeedbackFragment() { + + interface OpenEndedFeedbackListener { + fun userProvidedNegativeOpenEndedFeedback(mainReason: MainReason, subReason: SubReason?, feedback: String) + fun userProvidedPositiveOpenEndedFeedback(feedback: String) + fun userCancelled() + } + + private val viewModel by bindViewModel() + + private val listener: OpenEndedFeedbackListener? + get() = activity as OpenEndedFeedbackListener + + private var isPositiveFeedback: Boolean = true + + private var mainReason: MainReason? = null + private var subReason: SubReason? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.content_feedback_open_ended_feedback, container, false) + } + + override fun configureViewModelObservers() { + viewModel.command.observe(this, Observer { command -> + when (command) { + is Command.Exit -> { + listener?.userCancelled() + } + is Command.ExitAndSubmitNegativeFeedback -> { + listener?.userProvidedNegativeOpenEndedFeedback(command.mainReason, command.subReason, command.feedback) + } + is Command.ExitAndSubmitPositiveFeedback -> { + listener?.userProvidedPositiveOpenEndedFeedback(command.feedback) + } + } + }) + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + if (arguments == null) throw IllegalArgumentException("Missing required arguments") + + arguments?.let { args -> + isPositiveFeedback = args.getBoolean(IS_POSITIVE_FEEDBACK_EXTRA) + + if (isPositiveFeedback) { + updateDisplayForPositiveFeedback() + } else { + updateDisplayForNegativeFeedback(args) + } + } + } + + private fun updateDisplayForPositiveFeedback() { + title.text = getString(R.string.feedbackShareDetails) + subtitle.text = getString(R.string.sharePositiveFeedbackWithTheTeam) + openEndedFeedbackContainer.hint = getString(R.string.whatHaveYouBeenEnjoying) + emoticonImage.setImageResource(R.drawable.ic_happy_face) + } + + private fun updateDisplayForNegativeFeedback(args: Bundle) { + mainReason = args.getSerializable(MAIN_REASON_EXTRA) as MainReason + subReason = args.getSerializable(SUB_REASON_EXTRA) as SubReason? + + title.text = getDisplayText(mainReason!!) + subtitle.text = getDisplayText(subReason) + openEndedFeedbackContainer.hint = getInputHintText(mainReason!!) + emoticonImage.setImageResource(R.drawable.ic_sad_face) + } + + private fun getDisplayText(reason: MainReason): String { + val display = mainReasons[reason] ?: return "" + return getString(display.titleDisplayResId) + } + + private fun getDisplayText(reason: SubReason?): String { + val display = subReasons[reason] ?: return getString(R.string.tellUsHowToImprove) + return getString(display.subtitleDisplayResId) + } + + private fun getInputHintText(reason: MainReason): String { + return if (reason == MainReason.OTHER) { + getString(R.string.feedbackSpecificAsPossible) + } else { + getString(R.string.openEndedInputHint) + } + } + + override fun configureListeners() { + rootScrollView.doOnNextLayout { + openEndedFeedback.setOnTouchListener(LayoutScrollingTouchListener(rootScrollView, openEndedFeedbackContainer.y.toInt())) + } + + submitFeedbackButton.setOnClickListener { + + val openEndedComment = openEndedFeedback.text.toString() + if (isPositiveFeedback) { + viewModel.userSubmittingPositiveFeedback(openEndedComment) + } else { + viewModel.userSubmittingNegativeFeedback(mainReason!!, subReason, openEndedComment) + } + } + } + + companion object { + + private const val MAIN_REASON_EXTRA = "MAIN_REASON_EXTRA" + private const val SUB_REASON_EXTRA = "SUB_REASON_EXTRA" + private const val IS_POSITIVE_FEEDBACK_EXTRA = "IS_POSITIVE_FEEDBACK_EXTRA" + + fun instanceNegativeFeedback(mainReason: MainReason, subReason: SubReason?): ShareOpenEndedFeedbackFragment { + val fragment = ShareOpenEndedFeedbackFragment() + fragment.arguments = Bundle().also { + it.putBoolean(IS_POSITIVE_FEEDBACK_EXTRA, false) + it.putSerializable(MAIN_REASON_EXTRA, mainReason) + + if (subReason != null) { + it.putSerializable(SUB_REASON_EXTRA, subReason) + } + } + return fragment + } + + fun instancePositiveFeedback(): ShareOpenEndedFeedbackFragment { + val fragment = ShareOpenEndedFeedbackFragment() + fragment.arguments = Bundle().also { + it.putBoolean(IS_POSITIVE_FEEDBACK_EXTRA, true) + } + return fragment + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/openended/ShareOpenEndedNegativeFeedbackViewModel.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/openended/ShareOpenEndedNegativeFeedbackViewModel.kt new file mode 100644 index 000000000000..797cb240642d --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/openended/ShareOpenEndedNegativeFeedbackViewModel.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.negative.openended + +import androidx.lifecycle.ViewModel +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.SubReason +import com.duckduckgo.app.global.SingleLiveEvent + + +class ShareOpenEndedNegativeFeedbackViewModel : ViewModel() { + + val command: SingleLiveEvent = SingleLiveEvent() + + fun userSubmittingPositiveFeedback(feedback: String) { + command.value = Command.ExitAndSubmitPositiveFeedback(feedback) + } + + fun userSubmittingNegativeFeedback(mainReason: MainReason, subReason: SubReason?, openEndedComment: String) { + command.value = Command.ExitAndSubmitNegativeFeedback(mainReason, subReason, openEndedComment) + } + + sealed class Command { + data class ExitAndSubmitNegativeFeedback(val mainReason: MainReason, val subReason: SubReason?, val feedback: String) : Command() + data class ExitAndSubmitPositiveFeedback(val feedback: String) : Command() + object Exit : Command() + } +} + diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/subreason/SubReasonAdapter.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/subreason/SubReasonAdapter.kt new file mode 100644 index 000000000000..957b19aa7f8a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/subreason/SubReasonAdapter.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.negative.subreason + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.feedback.ui.negative.FeedbackTypeDisplay.FeedbackTypeSubReasonDisplay +import kotlinx.android.synthetic.main.item_feedback_reason.view.* + + +class SubReasonAdapter(private val itemClickListener: (FeedbackTypeSubReasonDisplay) -> Unit) : + ListAdapter(DiffCallback()) { + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FeedbackTypeSubReasonDisplay, newItem: FeedbackTypeSubReasonDisplay): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: FeedbackTypeSubReasonDisplay, newItem: FeedbackTypeSubReasonDisplay): Boolean { + return oldItem == newItem + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val root = inflater.inflate(R.layout.item_feedback_reason, parent, false) + return ViewHolder(root, root.reason) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position), itemClickListener) + } + + data class ViewHolder(val root: View, val reasonTextView: TextView) : RecyclerView.ViewHolder(root) { + + fun bind(reason: FeedbackTypeSubReasonDisplay, clickListener: (FeedbackTypeSubReasonDisplay) -> Unit) { + reasonTextView.text = root.context.getString(reason.listDisplayResId) + itemView.setOnClickListener { clickListener(reason) } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/subreason/SubReasonNegativeFeedbackFragment.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/subreason/SubReasonNegativeFeedbackFragment.kt new file mode 100644 index 000000000000..c6b88d46033e --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/negative/subreason/SubReasonNegativeFeedbackFragment.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.negative.subreason + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.LinearLayoutManager +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.feedback.ui.common.FeedbackFragment +import com.duckduckgo.app.feedback.ui.common.FeedbackItemDecoration +import com.duckduckgo.app.feedback.ui.negative.FeedbackType +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackTypeDisplay +import com.duckduckgo.app.feedback.ui.negative.FeedbackTypeDisplay.FeedbackTypeSubReasonDisplay +import com.duckduckgo.app.feedback.ui.negative.displayText +import kotlinx.android.synthetic.main.content_feedback_negative_disambiguation_sub_reason.* +import timber.log.Timber + + +class SubReasonNegativeFeedbackFragment : FeedbackFragment() { + + private lateinit var recyclerAdapter: SubReasonAdapter + + interface DisambiguationNegativeFeedbackListener { + fun userSelectedSubReasonMissingBrowserFeatures(mainReason: MainReason, subReason: FeedbackType.MissingBrowserFeaturesSubReasons) + fun userSelectedSubReasonSearchNotGoodEnough(mainReason: MainReason, subReason: FeedbackType.SearchNotGoodEnoughSubReasons) + fun userSelectedSubReasonNeedMoreCustomization(mainReason: MainReason, subReason: FeedbackType.CustomizationSubReasons) + fun userSelectedSubReasonAppIsSlowOrBuggy(mainReason: MainReason, subReason: FeedbackType.PerformanceSubReasons) + } + + private val listener: DisambiguationNegativeFeedbackListener? + get() = activity as DisambiguationNegativeFeedbackListener + + private lateinit var mainReason: MainReason + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val rootView = inflater.inflate(R.layout.content_feedback_negative_disambiguation_sub_reason, container, false) + + recyclerAdapter = SubReasonAdapter(object : (FeedbackTypeSubReasonDisplay) -> Unit { + override fun invoke(reason: FeedbackTypeSubReasonDisplay) { + when (reason.subReason) { + is MissingBrowserFeaturesSubReasons -> { + listener?.userSelectedSubReasonMissingBrowserFeatures(mainReason, reason.subReason) + } + is SearchNotGoodEnoughSubReasons -> { + listener?.userSelectedSubReasonSearchNotGoodEnough(mainReason, reason.subReason) + } + is CustomizationSubReasons -> { + listener?.userSelectedSubReasonNeedMoreCustomization(mainReason, reason.subReason) + } + is PerformanceSubReasons -> { + listener?.userSelectedSubReasonAppIsSlowOrBuggy(mainReason, reason.subReason) + } + } + } + }) + + return rootView + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + activity?.let { + recyclerView.layoutManager = LinearLayoutManager(it) + recyclerView.adapter = recyclerAdapter + recyclerView.addItemDecoration(FeedbackItemDecoration(ContextCompat.getDrawable(it, R.drawable.feedback_list_divider)!!)) + + + arguments?.let { args -> + + mainReason = args.getSerializable(MAIN_REASON_EXTRA) as MainReason + val display = mainReason.displayText() + + title.text = getString(display!!.titleDisplayResId) + subtitle.text = getString(display.subtitleDisplayResId) + + val subReasons = getDisplayTextForReasonType(mainReason) + Timber.i("There are ${subReasons.size} subReasons to show") + recyclerAdapter.submitList(subReasons) + } + } + } + + private fun getDisplayTextForReasonType(mainReason: MainReason): List { + return when (mainReason) { + MISSING_BROWSING_FEATURES -> browserFeatureSubReasons() + SEARCH_NOT_GOOD_ENOUGH -> searchNotGoodEnoughSubReasons() + NOT_ENOUGH_CUSTOMIZATIONS -> moreCustomizationsSubReasons() + APP_IS_SLOW_OR_BUGGY -> appSlowOrBuggySubReasons() + else -> throw IllegalStateException("Not handled - $mainReason") + } + } + + private fun browserFeatureSubReasons(): List { + return MissingBrowserFeaturesSubReasons.values().mapNotNull { + FeedbackTypeDisplay.subReasons[it] + } + } + + private fun searchNotGoodEnoughSubReasons(): List { + return SearchNotGoodEnoughSubReasons.values().mapNotNull { + FeedbackTypeDisplay.subReasons[it] + } + } + + private fun moreCustomizationsSubReasons(): List { + return FeedbackType.CustomizationSubReasons.values().mapNotNull { + FeedbackTypeDisplay.subReasons[it] + } + } + + private fun appSlowOrBuggySubReasons(): List { + return FeedbackType.PerformanceSubReasons.values().mapNotNull { + FeedbackTypeDisplay.subReasons[it] + } + } + + override fun configureViewModelObservers() {} + + companion object { + + private const val MAIN_REASON_EXTRA = "MAIN_REASON_EXTRA" + + fun instance(mainReason: MainReason): SubReasonNegativeFeedbackFragment { + val fragment = SubReasonNegativeFeedbackFragment() + fragment.arguments = Bundle().also { + it.putSerializable(MAIN_REASON_EXTRA, mainReason) + } + return fragment + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/positive/initial/PositiveFeedbackLandingFragment.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/positive/initial/PositiveFeedbackLandingFragment.kt new file mode 100644 index 000000000000..2e15d9ed0a4e --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/positive/initial/PositiveFeedbackLandingFragment.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.positive.initial + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.Observer +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.feedback.ui.common.FeedbackFragment +import com.duckduckgo.app.playstore.PlayStoreUtils +import kotlinx.android.synthetic.main.content_feedback_positive_landing.* +import javax.inject.Inject + + +class PositiveFeedbackLandingFragment : FeedbackFragment() { + + interface PositiveFeedbackLandingListener { + fun userSelectedToRateApp() + fun userSelectedToGiveFeedback() + fun userGavePositiveFeedbackNoDetails() + } + + private val viewModel by bindViewModel() + + private val listener: PositiveFeedbackLandingListener? + get() = activity as PositiveFeedbackLandingListener + + @Inject + lateinit var playStoreUtils: PlayStoreUtils + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.content_feedback_positive_landing, container, false) + } + + override fun configureViewModelObservers() { + viewModel.command.observe(this, Observer { command -> + when (command) { + Command.LaunchPlayStore -> { + launchPlayStore() + listener?.userSelectedToRateApp() + } + Command.Exit -> { + listener?.userGavePositiveFeedbackNoDetails() + } + Command.LaunchShareFeedbackPage -> { + listener?.userSelectedToGiveFeedback() + } + } + }) + } + + override fun configureListeners() { + rateAppButton.setOnClickListener { viewModel.userSelectedToRateApp() } + shareFeedbackButton.setOnClickListener { viewModel.userSelectedToProvideFeedbackDetails() } + cancelButton.setOnClickListener { viewModel.userFinishedGivingPositiveFeedback() } + } + + private fun launchPlayStore() { + playStoreUtils.launchPlayStore() + } + + companion object { + fun instance(): PositiveFeedbackLandingFragment { + return PositiveFeedbackLandingFragment() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/positive/initial/PositiveFeedbackLandingViewModel.kt b/app/src/main/java/com/duckduckgo/app/feedback/ui/positive/initial/PositiveFeedbackLandingViewModel.kt new file mode 100644 index 000000000000..5c7624447b6c --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/feedback/ui/positive/initial/PositiveFeedbackLandingViewModel.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.feedback.ui.positive.initial + +import androidx.lifecycle.ViewModel +import com.duckduckgo.app.global.SingleLiveEvent + + +class PositiveFeedbackLandingViewModel : ViewModel() { + + val command: SingleLiveEvent = SingleLiveEvent() + + fun userSelectedToRateApp() { + command.value = Command.LaunchPlayStore + } + + fun userSelectedToProvideFeedbackDetails() { + command.value = Command.LaunchShareFeedbackPage + } + + fun userFinishedGivingPositiveFeedback() { + command.value = Command.Exit + } +} + +data class ViewState(val canShowRatingButton: Boolean) + +sealed class Command { + object LaunchPlayStore : Command() + object Exit : Command() + object LaunchShareFeedbackPage : Command() +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt index bd8ada27e345..629d812e642d 100644 --- a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt @@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModelProvider import com.duckduckgo.app.autocomplete.api.AutoCompleteApi import com.duckduckgo.app.bookmarks.db.BookmarksDao import com.duckduckgo.app.bookmarks.ui.BookmarksViewModel +import com.duckduckgo.app.brokensite.BrokenSiteViewModel +import com.duckduckgo.app.brokensite.api.BrokenSiteSender import com.duckduckgo.app.browser.* import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector @@ -28,10 +30,12 @@ import com.duckduckgo.app.browser.favicon.FaviconDownloader import com.duckduckgo.app.browser.omnibar.QueryUrlConverter import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.cta.ui.CtaViewModel -import com.duckduckgo.app.feedback.api.FeedbackSender -import com.duckduckgo.app.feedback.db.SurveyDao -import com.duckduckgo.app.feedback.ui.FeedbackViewModel -import com.duckduckgo.app.feedback.ui.SurveyViewModel +import com.duckduckgo.app.feedback.api.FeedbackSubmitter +import com.duckduckgo.app.feedback.ui.common.FeedbackViewModel +import com.duckduckgo.app.feedback.ui.initial.InitialFeedbackFragmentViewModel +import com.duckduckgo.app.feedback.ui.negative.brokensite.BrokenSiteNegativeFeedbackViewModel +import com.duckduckgo.app.feedback.ui.negative.openended.ShareOpenEndedNegativeFeedbackViewModel +import com.duckduckgo.app.feedback.ui.positive.initial.PositiveFeedbackLandingViewModel import com.duckduckgo.app.fire.DataClearer import com.duckduckgo.app.global.db.AppConfigurationDao import com.duckduckgo.app.global.install.AppInstallStore @@ -41,6 +45,7 @@ import com.duckduckgo.app.global.rating.AppEnjoymentUserEventRecorder import com.duckduckgo.app.launch.LaunchViewModel import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.ui.OnboardingViewModel +import com.duckduckgo.app.playstore.PlayStoreUtils import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao import com.duckduckgo.app.privacy.store.PrivacySettingsSharedPreferences import com.duckduckgo.app.privacy.ui.PrivacyDashboardViewModel @@ -53,6 +58,8 @@ import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.app.survey.db.SurveyDao +import com.duckduckgo.app.survey.ui.SurveyViewModel import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel import com.duckduckgo.app.usage.search.SearchCountDao @@ -80,7 +87,7 @@ class ViewModelFactory @Inject constructor( private val webViewLongPressHandler: LongPressHandler, private val defaultBrowserDetector: DefaultBrowserDetector, private val variantManager: VariantManager, - private val feedbackSender: FeedbackSender, + private val brokenSiteSender: BrokenSiteSender, private val webViewSessionStorage: WebViewSessionStorage, private val specialUrlDetector: SpecialUrlDetector, private val faviconDownloader: FaviconDownloader, @@ -90,7 +97,9 @@ class ViewModelFactory @Inject constructor( private val ctaViewModel: CtaViewModel, private val appEnjoymentPromptEmitter: AppEnjoymentPromptEmitter, private val searchCountDao: SearchCountDao, - private val appEnjoymentUserEventRecorder: AppEnjoymentUserEventRecorder + private val appEnjoymentUserEventRecorder: AppEnjoymentUserEventRecorder, + private val playStoreUtils: PlayStoreUtils, + private val feedbackSubmitter: FeedbackSubmitter ) : ViewModelProvider.NewInstanceFactory() { override fun create(modelClass: Class) = @@ -105,11 +114,17 @@ class ViewModelFactory @Inject constructor( isAssignableFrom(ScorecardViewModel::class.java) -> ScorecardViewModel(privacySettingsStore) isAssignableFrom(TrackerNetworksViewModel::class.java) -> TrackerNetworksViewModel() isAssignableFrom(PrivacyPracticesViewModel::class.java) -> PrivacyPracticesViewModel() - isAssignableFrom(FeedbackViewModel::class.java) -> FeedbackViewModel(feedbackSender) + isAssignableFrom(FeedbackViewModel::class.java) -> FeedbackViewModel(playStoreUtils, feedbackSubmitter) + isAssignableFrom(BrokenSiteViewModel::class.java) -> BrokenSiteViewModel(brokenSiteSender) isAssignableFrom(SurveyViewModel::class.java) -> SurveyViewModel(surveyDao, statisticsStore, appInstallStore) isAssignableFrom(AddWidgetInstructionsViewModel::class.java) -> AddWidgetInstructionsViewModel() isAssignableFrom(SettingsViewModel::class.java) -> settingsViewModel() isAssignableFrom(BookmarksViewModel::class.java) -> BookmarksViewModel(bookmarksDao) + isAssignableFrom(InitialFeedbackFragmentViewModel::class.java) -> InitialFeedbackFragmentViewModel() + isAssignableFrom(PositiveFeedbackLandingViewModel::class.java) -> PositiveFeedbackLandingViewModel() + isAssignableFrom(ShareOpenEndedNegativeFeedbackViewModel::class.java) -> ShareOpenEndedNegativeFeedbackViewModel() + isAssignableFrom(BrokenSiteNegativeFeedbackViewModel::class.java) -> BrokenSiteNegativeFeedbackViewModel() + else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") } } as T diff --git a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt index 7fdf73441472..66682ba95875 100644 --- a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt +++ b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt @@ -31,8 +31,6 @@ import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.DismissedCta import com.duckduckgo.app.entities.db.EntityListDao import com.duckduckgo.app.entities.db.EntityListEntity -import com.duckduckgo.app.feedback.db.SurveyDao -import com.duckduckgo.app.feedback.model.Survey import com.duckduckgo.app.httpsupgrade.db.HttpsBloomFilterSpecDao import com.duckduckgo.app.httpsupgrade.db.HttpsWhitelistDao import com.duckduckgo.app.httpsupgrade.model.HttpsBloomFilterSpec @@ -42,6 +40,8 @@ import com.duckduckgo.app.notification.model.Notification import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao import com.duckduckgo.app.privacy.db.NetworkLeaderboardEntry import com.duckduckgo.app.privacy.db.SiteVisitedEntity +import com.duckduckgo.app.survey.db.SurveyDao +import com.duckduckgo.app.survey.model.Survey import com.duckduckgo.app.tabs.db.TabsDao import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabSelectionEntity diff --git a/app/src/main/java/com/duckduckgo/app/global/model/SiteMonitor.kt b/app/src/main/java/com/duckduckgo/app/global/model/SiteMonitor.kt index 3b687545cf20..44f32211bbbc 100644 --- a/app/src/main/java/com/duckduckgo/app/global/model/SiteMonitor.kt +++ b/app/src/main/java/com/duckduckgo/app/global/model/SiteMonitor.kt @@ -19,7 +19,10 @@ package com.duckduckgo.app.global.model import android.net.Uri import com.duckduckgo.app.global.baseHost import com.duckduckgo.app.global.isHttps -import com.duckduckgo.app.privacy.model.* +import com.duckduckgo.app.privacy.model.Grade +import com.duckduckgo.app.privacy.model.HttpsStatus +import com.duckduckgo.app.privacy.model.PrivacyGrade +import com.duckduckgo.app.privacy.model.PrivacyPractices import com.duckduckgo.app.privacy.store.PrevalenceStore import com.duckduckgo.app.trackerdetection.model.TrackerNetwork import com.duckduckgo.app.trackerdetection.model.TrackingEvent diff --git a/app/src/main/java/com/duckduckgo/app/global/rating/PromptTypeDecider.kt b/app/src/main/java/com/duckduckgo/app/global/rating/PromptTypeDecider.kt index cc6cb4d253f2..1d4dbf78038a 100644 --- a/app/src/main/java/com/duckduckgo/app/global/rating/PromptTypeDecider.kt +++ b/app/src/main/java/com/duckduckgo/app/global/rating/PromptTypeDecider.kt @@ -62,7 +62,7 @@ class InitialPromptTypeDecider( } private fun isPlayStoreInstalled(): Boolean { - if (!playStoreUtils.isPlayStoreInstalled(context)) { + if (!playStoreUtils.isPlayStoreInstalled()) { Timber.i("Play Store is not installed; cannot show ratings app enjoyment prompts") return false } @@ -70,7 +70,7 @@ class InitialPromptTypeDecider( } private fun wasInstalledThroughPlayStore(): Boolean { - if (!playStoreUtils.installedFromPlayStore(context)) { + if (!playStoreUtils.installedFromPlayStore()) { Timber.i("DuckDuckGo was not installed from Play Store") return if (BuildConfig.DEBUG) { diff --git a/app/src/main/java/com/duckduckgo/app/job/AppConfigurationDownloader.kt b/app/src/main/java/com/duckduckgo/app/job/AppConfigurationDownloader.kt index d9f2b8a8ce98..80887400d987 100644 --- a/app/src/main/java/com/duckduckgo/app/job/AppConfigurationDownloader.kt +++ b/app/src/main/java/com/duckduckgo/app/job/AppConfigurationDownloader.kt @@ -17,7 +17,7 @@ package com.duckduckgo.app.job import com.duckduckgo.app.entities.api.EntityListDownloader -import com.duckduckgo.app.feedback.api.SurveyDownloader +import com.duckduckgo.app.survey.api.SurveyDownloader import com.duckduckgo.app.global.db.AppConfigurationEntity import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.httpsupgrade.api.HttpsUpgradeDataDownloader diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingActivity.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingActivity.kt index 88bc501a3459..23a93fb9263c 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingActivity.kt @@ -19,9 +19,9 @@ package com.duckduckgo.app.onboarding.ui import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.View import androidx.annotation.ColorInt import androidx.core.content.ContextCompat -import android.view.View import com.duckduckgo.app.browser.R import com.duckduckgo.app.global.DuckDuckGoActivity import com.duckduckgo.app.global.view.ColorCombiner @@ -67,7 +67,7 @@ class OnboardingActivity : DuckDuckGoActivity() { val pageListener = ColorChangingPageListener(colorCombiner, object : NewColorListener { override fun update(@ColorInt color: Int) = updateColor(color) override fun getColorForPage(position: Int): Int? { - val color = viewPageAdapter.getItem(position)?.backgroundColor() ?: return null + val color = viewPageAdapter.backgroundColor(position) ?: return null return ContextCompat.getColor(this@OnboardingActivity, color) } }) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageFragment.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageFragment.kt index 72eb4f4377d2..a27516a2ded5 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageFragment.kt @@ -71,7 +71,7 @@ sealed class OnboardingPageFragment : Fragment() { @Inject lateinit var defaultBrowserDetector: DefaultBrowserDetector - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { AndroidSupportInjection.inject(this) super.onAttach(context) } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/PagerAdapter.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/PagerAdapter.kt index 713d0ad7f889..14f20e423b2c 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/PagerAdapter.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/PagerAdapter.kt @@ -21,7 +21,6 @@ import androidx.annotation.ColorInt import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentPagerAdapter -import com.duckduckgo.app.browser.R class PagerAdapter(fragmentManager: FragmentManager, private val viewModel: OnboardingViewModel) : FragmentPagerAdapter(fragmentManager) { @@ -29,13 +28,18 @@ class PagerAdapter(fragmentManager: FragmentManager, private val viewModel: Onbo return viewModel.pageCount() } - override fun getItem(position: Int): OnboardingPageFragment? { - return viewModel.getItem(position) + override fun getItem(position: Int): OnboardingPageFragment { + return viewModel.getItem(position) ?: throw IllegalArgumentException("No items exists at position $position") } @ColorInt fun color(context: Context, currentPage: Int): Int { - val color = getItem(currentPage)?.backgroundColor() ?: R.color.lightOliveGreen + val color = getItem(currentPage).backgroundColor() return ContextCompat.getColor(context, color) } + + fun backgroundColor(position: Int): Int? { + val item = viewModel.getItem(position) ?: return null + return item.backgroundColor() + } } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/playstore/PlayStoreUtils.kt b/app/src/main/java/com/duckduckgo/app/playstore/PlayStoreUtils.kt index ca00e90bf803..e294e6446603 100644 --- a/app/src/main/java/com/duckduckgo/app/playstore/PlayStoreUtils.kt +++ b/app/src/main/java/com/duckduckgo/app/playstore/PlayStoreUtils.kt @@ -25,14 +25,14 @@ import timber.log.Timber interface PlayStoreUtils { - fun installedFromPlayStore(context: Context): Boolean - fun launchPlayStore(context: Context) - fun isPlayStoreInstalled(context: Context): Boolean + fun installedFromPlayStore(): Boolean + fun launchPlayStore() + fun isPlayStoreInstalled(): Boolean } -class PlayStoreAndroidUtils : PlayStoreUtils { +class PlayStoreAndroidUtils(val context: Context) : PlayStoreUtils { - override fun installedFromPlayStore(context: Context): Boolean { + override fun installedFromPlayStore(): Boolean { return try { val installSource = context.packageManager.getInstallerPackageName(DDG_APP_PACKAGE) return matchesPlayStoreInstallSource(installSource) @@ -47,7 +47,7 @@ class PlayStoreAndroidUtils : PlayStoreUtils { return installSource == PLAY_STORE_PACKAGE } - override fun isPlayStoreInstalled(context: Context): Boolean { + override fun isPlayStoreInstalled(): Boolean { return try { if (!isPlayStoreActivityResolvable(context)) { @@ -55,7 +55,7 @@ class PlayStoreAndroidUtils : PlayStoreUtils { return false } - val isAppEnabled = isPlayStoreAppEnabled(context) + val isAppEnabled = isPlayStoreAppEnabled() Timber.i("The Play Store app is installed " + if (isAppEnabled) "and enabled" else "but disabled") return isAppEnabled @@ -65,7 +65,7 @@ class PlayStoreAndroidUtils : PlayStoreUtils { } } - private fun isPlayStoreAppEnabled(context: Context): Boolean { + private fun isPlayStoreAppEnabled(): Boolean { context.packageManager.getPackageInfo(PLAY_STORE_PACKAGE, 0) val appInfo = context.packageManager.getApplicationInfo(PLAY_STORE_PACKAGE, 0) return appInfo.enabled @@ -82,8 +82,9 @@ class PlayStoreAndroidUtils : PlayStoreUtils { } } - override fun launchPlayStore(context: Context) { + override fun launchPlayStore() { val intent = playStoreIntent() + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) try { context.startActivity(intent) } catch (e: ActivityNotFoundException) { diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt index c9583f231b66..fbfb43226c27 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.settings +import android.app.Activity import android.app.ActivityOptions import android.content.Context import android.content.Intent @@ -23,12 +24,13 @@ import android.os.Build import android.os.Bundle import android.view.View import android.widget.CompoundButton.OnCheckedChangeListener +import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.widget.SwitchCompat import androidx.lifecycle.Observer import com.duckduckgo.app.about.AboutDuckDuckGoActivity import com.duckduckgo.app.browser.R -import com.duckduckgo.app.feedback.ui.FeedbackActivity +import com.duckduckgo.app.feedback.ui.common.FeedbackActivity import com.duckduckgo.app.global.DuckDuckGoActivity import com.duckduckgo.app.global.sendThemeChangedBroadcast import com.duckduckgo.app.global.view.launchDefaultAppActivity @@ -158,7 +160,7 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra private fun launchFeedback() { val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() - startActivity(Intent(FeedbackActivity.intent(this)), options) + startActivityForResult(Intent(FeedbackActivity.intent(this)), FEEDBACK_REQUEST_CODE, options) } override fun onAutomaticallyClearWhatOptionSelected(clearWhatSetting: ClearWhatOption) { @@ -169,6 +171,19 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra viewModel.onAutomaticallyWhenOptionSelected(clearWhenSetting) } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == FEEDBACK_REQUEST_CODE) { + handleFeedbackResult(resultCode) + } + super.onActivityResult(requestCode, resultCode, data) + } + + private fun handleFeedbackResult(resultCode: Int) { + if (resultCode == Activity.RESULT_OK) { + Toast.makeText(this, R.string.thanksForTheFeedback, Toast.LENGTH_LONG).show() + } + } + @StringRes private fun ClearWhatOption.nameStringResourceId(): Int { return when (this) { @@ -193,6 +208,7 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra companion object { private const val CLEAR_WHAT_DIALOG_TAG = "CLEAR_WHAT_DIALOG_FRAGMENT" private const val CLEAR_WHEN_DIALOG_TAG = "CLEAR_WHEN_DIALOG_FRAGMENT" + private const val FEEDBACK_REQUEST_CODE = 100 fun intent(context: Context): Intent { return Intent(context, SettingsActivity::class.java) diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsAutomaticallyClearWhatFragment.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsAutomaticallyClearWhatFragment.kt index 7bdb112ab83f..ebd70e944e7a 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsAutomaticallyClearWhatFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsAutomaticallyClearWhatFragment.kt @@ -45,14 +45,16 @@ class SettingsAutomaticallyClearWhatFragment : DialogFragment() { .setView(rootView) .setTitle(R.string.settingsAutomaticallyClearWhat) .setPositiveButton(R.string.settingsAutomaticallyClearingDialogSave) { _, _ -> - val radioGroup = dialog.findViewById(R.id.settingsClearWhatGroup) as RadioGroup - val selectedOption = when (radioGroup.checkedRadioButtonId) { - R.id.settingTabsOnly -> CLEAR_TABS_ONLY - R.id.settingTabsAndData -> CLEAR_TABS_AND_DATA - else -> CLEAR_NONE + dialog?.let { + val radioGroup = it.findViewById(R.id.settingsClearWhatGroup) as RadioGroup + val selectedOption = when (radioGroup.checkedRadioButtonId) { + R.id.settingTabsOnly -> CLEAR_TABS_ONLY + R.id.settingTabsAndData -> CLEAR_TABS_AND_DATA + else -> CLEAR_NONE + } + val listener = activity as Listener? + listener?.onAutomaticallyClearWhatOptionSelected(selectedOption) } - val listener = activity as Listener? - listener?.onAutomaticallyClearWhatOptionSelected(selectedOption) } .setNegativeButton(android.R.string.cancel) { _, _ -> } diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsAutomaticallyClearWhenFragment.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsAutomaticallyClearWhenFragment.kt index 74e6c0667780..1cd107ebfaa5 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsAutomaticallyClearWhenFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsAutomaticallyClearWhenFragment.kt @@ -52,17 +52,19 @@ class SettingsAutomaticallyClearWhenFragment : DialogFragment() { .setView(rootView) .setTitle(R.string.settingsAutomaticallyClearWhat) .setPositiveButton(R.string.settingsAutomaticallyClearingDialogSave) { _, _ -> - val radioGroup = dialog.findViewById(R.id.settingsClearWhenGroup) as RadioGroup - val selectedOption = when (radioGroup.checkedRadioButtonId) { - R.id.settingInactive5Mins -> APP_EXIT_OR_5_MINS - R.id.settingInactive15Mins -> APP_EXIT_OR_15_MINS - R.id.settingInactive30Mins -> APP_EXIT_OR_30_MINS - R.id.settingInactive60Mins -> APP_EXIT_OR_60_MINS - R.id.settingInactive5Seconds -> APP_EXIT_OR_5_SECONDS - else -> APP_EXIT_ONLY + dialog?.let { + val radioGroup = it.findViewById(R.id.settingsClearWhenGroup) as RadioGroup + val selectedOption = when (radioGroup.checkedRadioButtonId) { + R.id.settingInactive5Mins -> APP_EXIT_OR_5_MINS + R.id.settingInactive15Mins -> APP_EXIT_OR_15_MINS + R.id.settingInactive30Mins -> APP_EXIT_OR_30_MINS + R.id.settingInactive60Mins -> APP_EXIT_OR_60_MINS + R.id.settingInactive5Seconds -> APP_EXIT_OR_5_SECONDS + else -> APP_EXIT_ONLY + } + val listener = activity as Listener? + listener?.onAutomaticallyClearWhenOptionSelected(selectedOption) } - val listener = activity as Listener? - listener?.onAutomaticallyClearWhenOptionSelected(selectedOption) } .setNegativeButton(android.R.string.cancel) { _, _ -> } diff --git a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt index 954f9c989c97..e2d2acd8630c 100644 --- a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt +++ b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt @@ -106,6 +106,9 @@ interface Pixel { APP_FEEDBACK_DIALOG_USER_GAVE_FEEDBACK("mrp_f_d%d_y"), APP_FEEDBACK_DIALOG_USER_DECLINED_FEEDBACK("mrp_f_d%d_n"), APP_FEEDBACK_DIALOG_USER_CANCELLED("mrp_f_d%d_c"), + + FEEDBACK_POSITIVE_SUBMISSION("mfbs_%s_submit"), + FEEDBACK_NEGATIVE_SUBMISSION("mfbs_%s_%s_%s") } object PixelParameter { @@ -138,7 +141,7 @@ class ApiBasedPixel @Inject constructor( .subscribe({ Timber.v("Pixel sent: $pixelName") }, { - Timber.w("Pixel failed: $pixelName", it) + Timber.w("Pixel failed: $pixelName") }) } diff --git a/app/src/main/java/com/duckduckgo/app/feedback/api/SurveyDownloader.kt b/app/src/main/java/com/duckduckgo/app/survey/api/SurveyDownloader.kt similarity index 87% rename from app/src/main/java/com/duckduckgo/app/feedback/api/SurveyDownloader.kt rename to app/src/main/java/com/duckduckgo/app/survey/api/SurveyDownloader.kt index 4856d75995f1..24e1f7b60c11 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/api/SurveyDownloader.kt +++ b/app/src/main/java/com/duckduckgo/app/survey/api/SurveyDownloader.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2019 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,13 +14,13 @@ * limitations under the License. */ -package com.duckduckgo.app.feedback.api +package com.duckduckgo.app.survey.api -import com.duckduckgo.app.feedback.api.SurveyGroup.SurveyOption -import com.duckduckgo.app.feedback.db.SurveyDao -import com.duckduckgo.app.feedback.model.Survey -import com.duckduckgo.app.feedback.model.Survey.Status.NOT_ALLOCATED -import com.duckduckgo.app.feedback.model.Survey.Status.SCHEDULED +import com.duckduckgo.app.survey.api.SurveyGroup.SurveyOption +import com.duckduckgo.app.survey.db.SurveyDao +import com.duckduckgo.app.survey.model.Survey +import com.duckduckgo.app.survey.model.Survey.Status.NOT_ALLOCATED +import com.duckduckgo.app.survey.model.Survey.Status.SCHEDULED import io.reactivex.Completable import timber.log.Timber import java.io.IOException diff --git a/app/src/main/java/com/duckduckgo/app/feedback/api/SurveyGroup.kt b/app/src/main/java/com/duckduckgo/app/survey/api/SurveyGroup.kt similarity index 91% rename from app/src/main/java/com/duckduckgo/app/feedback/api/SurveyGroup.kt rename to app/src/main/java/com/duckduckgo/app/survey/api/SurveyGroup.kt index 23127585a5a6..5f71490567ac 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/api/SurveyGroup.kt +++ b/app/src/main/java/com/duckduckgo/app/survey/api/SurveyGroup.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2019 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.app.feedback.api +package com.duckduckgo.app.survey.api data class SurveyGroup(val id: String, val surveyOptions: List) { diff --git a/app/src/main/java/com/duckduckgo/app/feedback/api/SurveyService.kt b/app/src/main/java/com/duckduckgo/app/survey/api/SurveyService.kt similarity index 91% rename from app/src/main/java/com/duckduckgo/app/feedback/api/SurveyService.kt rename to app/src/main/java/com/duckduckgo/app/survey/api/SurveyService.kt index 466f904bb930..c3cd8ea893d1 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/api/SurveyService.kt +++ b/app/src/main/java/com/duckduckgo/app/survey/api/SurveyService.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2019 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.app.feedback.api +package com.duckduckgo.app.survey.api import retrofit2.Call import retrofit2.http.GET diff --git a/app/src/main/java/com/duckduckgo/app/feedback/db/SurveyDao.kt b/app/src/main/java/com/duckduckgo/app/survey/db/SurveyDao.kt similarity index 93% rename from app/src/main/java/com/duckduckgo/app/feedback/db/SurveyDao.kt rename to app/src/main/java/com/duckduckgo/app/survey/db/SurveyDao.kt index e32fe909e2b4..a8db876091f3 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/db/SurveyDao.kt +++ b/app/src/main/java/com/duckduckgo/app/survey/db/SurveyDao.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2019 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,11 +14,11 @@ * limitations under the License. */ -package com.duckduckgo.app.feedback.db +package com.duckduckgo.app.survey.db import androidx.lifecycle.LiveData import androidx.room.* -import com.duckduckgo.app.feedback.model.Survey +import com.duckduckgo.app.survey.model.Survey @Dao abstract class SurveyDao { diff --git a/app/src/main/java/com/duckduckgo/app/feedback/model/Survey.kt b/app/src/main/java/com/duckduckgo/app/survey/model/Survey.kt similarity index 96% rename from app/src/main/java/com/duckduckgo/app/feedback/model/Survey.kt rename to app/src/main/java/com/duckduckgo/app/survey/model/Survey.kt index 4b217f2f52a4..6425091446d3 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/model/Survey.kt +++ b/app/src/main/java/com/duckduckgo/app/survey/model/Survey.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.app.feedback.model +package com.duckduckgo.app.survey.model import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/SurveyActivity.kt b/app/src/main/java/com/duckduckgo/app/survey/ui/SurveyActivity.kt similarity index 90% rename from app/src/main/java/com/duckduckgo/app/feedback/ui/SurveyActivity.kt rename to app/src/main/java/com/duckduckgo/app/survey/ui/SurveyActivity.kt index 19a050ab4395..cec9087d6b7e 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/ui/SurveyActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/survey/ui/SurveyActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2019 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.app.feedback.ui +package com.duckduckgo.app.survey.ui import android.annotation.SuppressLint import android.content.Context @@ -24,15 +24,14 @@ import android.webkit.* import androidx.core.content.ContextCompat import androidx.lifecycle.Observer import com.duckduckgo.app.browser.R -import com.duckduckgo.app.feedback.model.Survey -import com.duckduckgo.app.feedback.ui.SurveyViewModel.Command -import com.duckduckgo.app.feedback.ui.SurveyViewModel.Command.Close -import com.duckduckgo.app.feedback.ui.SurveyViewModel.Command.LoadSurvey import com.duckduckgo.app.global.DuckDuckGoActivity import com.duckduckgo.app.global.view.gone import com.duckduckgo.app.global.view.show import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.SURVEY_SURVEY_DISMISSED +import com.duckduckgo.app.survey.model.Survey +import com.duckduckgo.app.survey.ui.SurveyViewModel.Command +import com.duckduckgo.app.survey.ui.SurveyViewModel.Command.* import kotlinx.android.synthetic.main.activity_user_survey.* import javax.inject.Inject @@ -84,12 +83,8 @@ class SurveyActivity : DuckDuckGoActivity() { private fun processCommand(command: Command) { when (command) { is LoadSurvey -> loadSurvey(command.url) - is Command.ShowSurvey -> { - showSurvey() - } - is Command.ShowError -> { - showError() - } + is ShowSurvey -> showSurvey() + is ShowError -> showError() is Close -> finish() } } diff --git a/app/src/main/java/com/duckduckgo/app/feedback/ui/SurveyViewModel.kt b/app/src/main/java/com/duckduckgo/app/survey/ui/SurveyViewModel.kt similarity index 95% rename from app/src/main/java/com/duckduckgo/app/feedback/ui/SurveyViewModel.kt rename to app/src/main/java/com/duckduckgo/app/survey/ui/SurveyViewModel.kt index 64cbad64dd1f..25711a8e8994 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/ui/SurveyViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/survey/ui/SurveyViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2019 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,18 +14,18 @@ * limitations under the License. */ -package com.duckduckgo.app.feedback.ui +package com.duckduckgo.app.survey.ui import android.os.Build import androidx.core.net.toUri import androidx.lifecycle.ViewModel import com.duckduckgo.app.browser.BuildConfig -import com.duckduckgo.app.feedback.db.SurveyDao -import com.duckduckgo.app.feedback.model.Survey import com.duckduckgo.app.global.SingleLiveEvent import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.install.daysInstalled import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.app.survey.db.SurveyDao +import com.duckduckgo.app.survey.model.Survey import io.reactivex.schedulers.Schedulers diff --git a/app/src/main/res/anim/slide_from_left.xml b/app/src/main/res/anim/slide_from_left.xml new file mode 100644 index 000000000000..116691fb26c7 --- /dev/null +++ b/app/src/main/res/anim/slide_from_left.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_from_right.xml b/app/src/main/res/anim/slide_from_right.xml new file mode 100644 index 000000000000..607782f36738 --- /dev/null +++ b/app/src/main/res/anim/slide_from_right.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_to_left.xml b/app/src/main/res/anim/slide_to_left.xml new file mode 100644 index 000000000000..71a0dbdafe35 --- /dev/null +++ b/app/src/main/res/anim/slide_to_left.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_to_right.xml b/app/src/main/res/anim/slide_to_right.xml new file mode 100644 index 000000000000..209f225a11ac --- /dev/null +++ b/app/src/main/res/anim/slide_to_right.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/button_happy_dark_theme.png b/app/src/main/res/drawable-hdpi/button_happy_dark_theme.png new file mode 100644 index 000000000000..2023a9217d07 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/button_happy_dark_theme.png differ diff --git a/app/src/main/res/drawable-hdpi/button_happy_light_theme.png b/app/src/main/res/drawable-hdpi/button_happy_light_theme.png new file mode 100644 index 000000000000..c32c34a716d2 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/button_happy_light_theme.png differ diff --git a/app/src/main/res/drawable-hdpi/button_sad_dark_theme.png b/app/src/main/res/drawable-hdpi/button_sad_dark_theme.png new file mode 100644 index 000000000000..8c2f66d94704 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/button_sad_dark_theme.png differ diff --git a/app/src/main/res/drawable-hdpi/button_sad_light_theme.png b/app/src/main/res/drawable-hdpi/button_sad_light_theme.png new file mode 100644 index 000000000000..3a20b40191ca Binary files /dev/null and b/app/src/main/res/drawable-hdpi/button_sad_light_theme.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_happy_face.png b/app/src/main/res/drawable-hdpi/ic_happy_face.png new file mode 100644 index 000000000000..f1834b1b8869 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_happy_face.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_sad_face.png b/app/src/main/res/drawable-hdpi/ic_sad_face.png new file mode 100644 index 000000000000..2f5c6d79c340 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_sad_face.png differ diff --git a/app/src/main/res/drawable-mdpi/button_happy_dark_theme.png b/app/src/main/res/drawable-mdpi/button_happy_dark_theme.png new file mode 100644 index 000000000000..15c72b436370 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/button_happy_dark_theme.png differ diff --git a/app/src/main/res/drawable-mdpi/button_happy_light_theme.png b/app/src/main/res/drawable-mdpi/button_happy_light_theme.png new file mode 100644 index 000000000000..cc783d0c0758 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/button_happy_light_theme.png differ diff --git a/app/src/main/res/drawable-mdpi/button_sad_dark_theme.png b/app/src/main/res/drawable-mdpi/button_sad_dark_theme.png new file mode 100644 index 000000000000..da25e3f3486e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/button_sad_dark_theme.png differ diff --git a/app/src/main/res/drawable-mdpi/button_sad_light_theme.png b/app/src/main/res/drawable-mdpi/button_sad_light_theme.png new file mode 100644 index 000000000000..a0dac61033e7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/button_sad_light_theme.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_happy_face.png b/app/src/main/res/drawable-mdpi/ic_happy_face.png new file mode 100644 index 000000000000..37eb75d97b80 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_happy_face.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_sad_face.png b/app/src/main/res/drawable-mdpi/ic_sad_face.png new file mode 100644 index 000000000000..ad3dc205ea96 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_sad_face.png differ diff --git a/app/src/main/res/drawable-xhdpi/button_happy_dark_theme.png b/app/src/main/res/drawable-xhdpi/button_happy_dark_theme.png new file mode 100644 index 000000000000..634ddfd8f774 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/button_happy_dark_theme.png differ diff --git a/app/src/main/res/drawable-xhdpi/button_happy_light_theme.png b/app/src/main/res/drawable-xhdpi/button_happy_light_theme.png new file mode 100644 index 000000000000..80807b2008f7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/button_happy_light_theme.png differ diff --git a/app/src/main/res/drawable-xhdpi/button_sad_dark_theme.png b/app/src/main/res/drawable-xhdpi/button_sad_dark_theme.png new file mode 100644 index 000000000000..57dde218d9fc Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/button_sad_dark_theme.png differ diff --git a/app/src/main/res/drawable-xhdpi/button_sad_light_theme.png b/app/src/main/res/drawable-xhdpi/button_sad_light_theme.png new file mode 100644 index 000000000000..df7ed30f0f37 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/button_sad_light_theme.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_happy_face.png b/app/src/main/res/drawable-xhdpi/ic_happy_face.png new file mode 100644 index 000000000000..b38f937e6f57 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_happy_face.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_sad_face.png b/app/src/main/res/drawable-xhdpi/ic_sad_face.png new file mode 100644 index 000000000000..18b2f052a277 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_sad_face.png differ diff --git a/app/src/main/res/drawable-xxhdpi/button_happy_dark_theme.png b/app/src/main/res/drawable-xxhdpi/button_happy_dark_theme.png new file mode 100644 index 000000000000..1e9e97092345 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/button_happy_dark_theme.png differ diff --git a/app/src/main/res/drawable-xxhdpi/button_happy_light_theme.png b/app/src/main/res/drawable-xxhdpi/button_happy_light_theme.png new file mode 100644 index 000000000000..7a0376a7f4d0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/button_happy_light_theme.png differ diff --git a/app/src/main/res/drawable-xxhdpi/button_sad_dark_theme.png b/app/src/main/res/drawable-xxhdpi/button_sad_dark_theme.png new file mode 100644 index 000000000000..fc8d50932021 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/button_sad_dark_theme.png differ diff --git a/app/src/main/res/drawable-xxhdpi/button_sad_light_theme.png b/app/src/main/res/drawable-xxhdpi/button_sad_light_theme.png new file mode 100644 index 000000000000..fc4e31d439d4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/button_sad_light_theme.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_happy_face.png b/app/src/main/res/drawable-xxhdpi/ic_happy_face.png new file mode 100644 index 000000000000..7654a958aa27 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_happy_face.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_sad_face.png b/app/src/main/res/drawable-xxhdpi/ic_sad_face.png new file mode 100644 index 000000000000..db6f4bc40a4b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_sad_face.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/button_happy_dark_theme.png b/app/src/main/res/drawable-xxxhdpi/button_happy_dark_theme.png new file mode 100644 index 000000000000..2e31c6f8f2da Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/button_happy_dark_theme.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/button_happy_light_theme.png b/app/src/main/res/drawable-xxxhdpi/button_happy_light_theme.png new file mode 100644 index 000000000000..f565ed2e34db Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/button_happy_light_theme.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/button_sad_dark_theme.png b/app/src/main/res/drawable-xxxhdpi/button_sad_dark_theme.png new file mode 100644 index 000000000000..c743cb193279 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/button_sad_dark_theme.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/button_sad_light_theme.png b/app/src/main/res/drawable-xxxhdpi/button_sad_light_theme.png new file mode 100644 index 000000000000..be34f2c118e0 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/button_sad_light_theme.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_happy_face.png b/app/src/main/res/drawable-xxxhdpi/ic_happy_face.png new file mode 100644 index 000000000000..18f767f69a02 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_happy_face.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_sad_face.png b/app/src/main/res/drawable-xxxhdpi/ic_sad_face.png new file mode 100644 index 000000000000..652315cc9384 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_sad_face.png differ diff --git a/app/src/main/res/drawable/button_contained_bg.xml b/app/src/main/res/drawable/button_contained_bg.xml index ccda46429a10..43bed6803a74 100644 --- a/app/src/main/res/drawable/button_contained_bg.xml +++ b/app/src/main/res/drawable/button_contained_bg.xml @@ -15,9 +15,9 @@ --> - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/feedback_list_divider.xml b/app/src/main/res/drawable/feedback_list_divider.xml new file mode 100644 index 000000000000..d527681d96fc --- /dev/null +++ b/app/src/main/res/drawable/feedback_list_divider.xml @@ -0,0 +1,27 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_icon_heart_26dp.xml b/app/src/main/res/drawable/ic_icon_report_broken_site.xml similarity index 54% rename from app/src/main/res/drawable/ic_icon_heart_26dp.xml rename to app/src/main/res/drawable/ic_icon_report_broken_site.xml index b50ca645b1ec..9c230873979a 100644 --- a/app/src/main/res/drawable/ic_icon_heart_26dp.xml +++ b/app/src/main/res/drawable/ic_icon_report_broken_site.xml @@ -1,5 +1,5 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="32" + android:viewportHeight="32"> + android:pathData="M16.05,3C23.204,3 29.002,8.798 29,15.95c0,7.153 -5.8,12.952 -12.95,12.952 -2.83,0 -5.445,-0.912 -7.576,-2.45L3,28.901l2.225,-5.845A12.893,12.893 0,0 1,3.1 15.95C3.1,8.798 8.898,3 16.05,3zM16,9a1.79,1.79 0,0 0,-1.78 1.988l0.67,6.018a1.117,1.117 0,0 0,2.22 0l0.67,-6.018A1.79,1.79 0,0 0,16 9zM16,23a2,2 0,1 0,0 -4,2 2,0 0,0 0,4z" /> diff --git a/app/src/main/res/drawable/report_broken_site_button_outlined_bg.xml b/app/src/main/res/drawable/report_broken_site_button_outlined_bg.xml new file mode 100644 index 000000000000..a56aa21216c2 --- /dev/null +++ b/app/src/main/res/drawable/report_broken_site_button_outlined_bg.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_broken_site.xml b/app/src/main/res/layout/activity_broken_site.xml new file mode 100644 index 000000000000..c002e76be074 --- /dev/null +++ b/app/src/main/res/layout/activity_broken_site.xml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + +