From 0b19f2c177ee78cfb152957ae2fa0ac277b6e2d9 Mon Sep 17 00:00:00 2001 From: ChaseApptentive <85038972+ChaseApptentive@users.noreply.github.com> Date: Thu, 20 Jul 2023 12:44:45 -0400 Subject: [PATCH] Release/6.1.0 (#17) Release/6.1.0 --- CHANGELOG.md | 21 + Jenkinsfile => Jenkinsfile-old | 0 .../com/android/ui/ApptentivePagerAdapter.kt | 49 + .../com/android/ui/ApptentiveViewHolder.kt | 14 + .../com/android/ui/ListViewAdapter.kt | 22 +- .../com/android/ui/ViewHolderFactory.kt | 6 +- .../apptentive_custom_focus_overlay.xml | 9 + .../res/drawable/apptentive_pill_current.xml | 6 + .../res/drawable/apptentive_pill_next.xml | 6 + .../res/drawable/apptentive_pill_previous.xml | 6 + .../src/main/res/values-night/colors.xml | 2 +- .../src/main/res/values/apptentive-attrs.xml | 28 + .../src/main/res/values/colors.xml | 3 + .../src/main/res/values/dimens.xml | 25 +- .../src/main/res/values/styles.xml | 133 +- .../com/android/core/ResettableDelegate.kt | 29 + .../android/platform/SharedPrefConstants.kt | 1 + .../com/android/util/ListExtensions.kt | 3 + .../java/apptentive/com/android/util/Log.kt | 10 +- .../apptentive/com/android/util/LogTags.kt | 1 + .../com/android/util/StringUtils.kt | 10 + .../com/android/util/mapExtensions.kt | 3 +- .../assets/manifest/SurveyBranched-11Q.json | 609 ++ .../assets/manifest/SurveyBranched-3Q.json | 330 + .../assets/manifest/SurveyBranched-9Q.json | 532 ++ .../manifest/SurveyBranched-BehaviorEnd.json | 302 + .../manifest/SurveyBranched-v1-short.json | 533 ++ .../assets/manifest/SurveyBranched-v1.json | 609 ++ .../src/main/assets/manifest/SurveyV11.json | 4332 +++++++++++++ .../src/main/assets/manifest/SurveyV12.json | 5337 +++++++++++++++++ apptentive-example/src/main/assets/test.docx | Bin 0 -> 22222 bytes apptentive-example/src/main/assets/test.pdf | Bin 0 -> 12370 bytes apptentive-example/src/main/assets/test.pptx | Bin 0 -> 37331 bytes .../com/app/AppFirebaseMessagingService.kt | 80 + .../com/app/DevFunctionsActivity.kt | 622 ++ .../apptentive/com/app/FileItemAdapter.kt | 29 + .../main/java/apptentive/com/app/FileUtils.kt | 53 + .../main/java/apptentive/com/app/InfoUtil.kt | 59 + .../java/apptentive/com/app/MainActivity.java | 110 + .../apptentive/com/app/PermissionsUtils.kt | 23 + ...ook_1_message_center_button_background.xml | 15 + ...ok_1_message_center_compose_background.xml | 20 + ..._message_center_toolbar_top_background.xml | 6 + ...ok_2_message_center_compose_background.xml | 20 + ...ok_3_message_center_compose_background.xml | 20 + .../cookbook_3_message_center_toolbar_top.xml | 7 + .../drawable/ic_apptentive_notification.xml | 12 + .../res/drawable/note_example_background.xml | 7 + .../main/res/drawable/note_example_header.png | Bin 0 -> 30344 bytes .../res/drawable/rating_header_example.png | Bin 0 -> 338643 bytes .../src/main/res/font/montserrat.xml | 6 + .../res/layout/activity_dev_functions.xml | 708 +++ .../src/main/res/layout/file_item.xml | 6 + .../src/main/res/values/styles.xml | 97 + .../src/main/res/xml/provider_paths.xml | 6 + .../engagement/MockEngagementContext.kt | 12 + .../MockEngagementContextFactory.kt | 14 + .../engagement/MockInteractionDataProvider.kt | 4 + .../com/android/feedback/testData.kt | 2 +- apptentive-feedback/consumer-rules.pro | 3 +- .../com/android/feedback/Apptentive.kt | 23 + .../com/android/feedback/ApptentiveClient.kt | 6 + .../feedback/ApptentiveDefaultClient.kt | 12 +- .../com/android/feedback/Constants.kt | 4 +- .../conversation/ConversationManager.kt | 50 +- .../conversation/ConversationSerializer.kt | 558 +- .../conversation/DefaultSerializers.kt | 588 ++ .../feedback/engagement/DefaultEngagement.kt | 18 +- .../android/feedback/engagement/Engagement.kt | 20 + .../feedback/engagement/EngagementContext.kt | 8 + .../engagement/InteractionDataProvider.kt | 2 + .../criteria/ConditionalOperator.kt | 6 +- .../CriteriaInteractionDataProvider.kt | 4 + .../criteria/DefaultTargetingState.kt | 2 + .../feedback/engagement/criteria/Field.kt | 15 + .../criteria/InteractionResponseCriteria.kt | 10 + .../interactions/InteractionResponseData.kt | 23 +- .../android/feedback/model/EngagementData.kt | 26 + .../android/feedback/model/InvocationData.kt | 12 +- .../android/feedback/utils/ThrottleUtils.kt | 11 +- .../conversation/ConversationManagerTest.kt | 10 +- .../DefaultConversationSerializerTest.kt | 11 + .../feedback/conversation/SerializersTest.kt | 36 +- .../engagement/DefaultEngagementTest.kt | 6 +- .../feedback/model/ConversationTest.kt | 9 + .../messagecenter/view/ProfileActivity.kt | 2 + .../viewmodel/MessageCenterViewModelTest.kt | 15 +- .../feedback/survey/BaseSurveyActivity.kt | 10 +- .../android/feedback/survey/SurveyActivity.kt | 238 +- .../feedback/survey/SurveyModelFactory.kt | 30 +- .../survey/interaction/SurveyInteraction.kt | 36 +- .../SurveyInteractionTypeConverter.kt | 12 +- .../interaction/SurveyQuestionSetConverter.kt | 41 + .../survey/model/MultiChoiceQuestion.kt | 3 - .../survey/model/QuestionListSubject.kt | 59 + .../feedback/survey/model/RangeQuestion.kt | 3 - .../survey/model/SingleLineQuestion.kt | 11 +- .../survey/model/SurveyAnswerState.kt | 7 + .../feedback/survey/model/SurveyModel.kt | 238 +- .../feedback/survey/model/SurveyPageData.kt | 26 + .../feedback/survey/model/SurveyQuestion.kt | 4 +- .../survey/model/SurveyQuestionSet.kt | 21 + .../survey/model/SurveyResponsePayload.kt | 38 +- .../survey/utils/SurveyViewModelUtils.kt | 47 +- .../view/SurveyQuestionContainerView.kt | 16 +- .../view/SurveyQuestionViewHolderFactory.kt | 6 +- .../survey/view/SurveySegmentedProgressBar.kt | 85 + .../viewmodel/MultiChoiceQuestionListItem.kt | 4 +- .../viewmodel/SingleLineQuestionListItem.kt | 13 +- .../survey/viewmodel/SurveyFooterListItem.kt | 4 +- .../survey/viewmodel/SurveyHeaderListItem.kt | 8 +- .../viewmodel/SurveyIntroductionPageItem.kt | 64 + .../survey/viewmodel/SurveyListItem.kt | 6 + .../viewmodel/SurveyQuestionListItem.kt | 12 +- .../survey/viewmodel/SurveySuccessPageItem.kt | 63 + .../survey/viewmodel/SurveyViewModel.kt | 280 +- .../res/layout/apptentive_activity_survey.xml | 40 +- .../res/layout/apptentive_survey_footer.xml | 1 + .../res/layout/apptentive_survey_header.xml | 3 - .../layout/apptentive_survey_introduction.xml | 33 + .../apptentive_survey_question_container.xml | 7 +- ...ptentive_survey_segmented_progress_bar.xml | 6 + ...ive_survey_segmented_progress_bar_item.xml | 5 + .../layout/apptentive_survey_success_page.xml | 33 + .../src/main/res/values/strings.xml | 1 + .../SurveyInteractionConverterTest.kt | 89 +- .../SurveyInteractionLauncherTest.kt | 63 +- .../interaction/SurveyQuestionSetTest.kt | 60 + .../interaction/TermsAndConditionsTest.kt | 35 + .../survey/model/SingleLineQuestionTest.kt | 4 +- .../feedback/survey/model/SurveyModelTest.kt | 230 +- .../survey/model/SurveyResponsePayloadTest.kt | 35 +- .../feedback/survey/model/questions.kt | 79 + ...efaultSurveyQuestionListItemFactoryTest.kt | 8 +- .../SurveyInteractionResponseTest.kt | 68 + .../survey/viewmodel/SurveyViewModelTest.kt | 308 +- build.gradle | 4 +- local-template.properties | 15 + 138 files changed, 17725 insertions(+), 1186 deletions(-) rename Jenkinsfile => Jenkinsfile-old (100%) create mode 100644 apptentive-core-ui/src/main/java/apptentive/com/android/ui/ApptentivePagerAdapter.kt create mode 100644 apptentive-core-ui/src/main/java/apptentive/com/android/ui/ApptentiveViewHolder.kt create mode 100644 apptentive-core-ui/src/main/res/drawable/apptentive_custom_focus_overlay.xml create mode 100644 apptentive-core-ui/src/main/res/drawable/apptentive_pill_current.xml create mode 100644 apptentive-core-ui/src/main/res/drawable/apptentive_pill_next.xml create mode 100644 apptentive-core-ui/src/main/res/drawable/apptentive_pill_previous.xml create mode 100644 apptentive-core/src/main/java/apptentive/com/android/core/ResettableDelegate.kt create mode 100644 apptentive-example/src/main/assets/manifest/SurveyBranched-11Q.json create mode 100644 apptentive-example/src/main/assets/manifest/SurveyBranched-3Q.json create mode 100644 apptentive-example/src/main/assets/manifest/SurveyBranched-9Q.json create mode 100644 apptentive-example/src/main/assets/manifest/SurveyBranched-BehaviorEnd.json create mode 100644 apptentive-example/src/main/assets/manifest/SurveyBranched-v1-short.json create mode 100644 apptentive-example/src/main/assets/manifest/SurveyBranched-v1.json create mode 100644 apptentive-example/src/main/assets/manifest/SurveyV11.json create mode 100644 apptentive-example/src/main/assets/manifest/SurveyV12.json create mode 100644 apptentive-example/src/main/assets/test.docx create mode 100644 apptentive-example/src/main/assets/test.pdf create mode 100644 apptentive-example/src/main/assets/test.pptx create mode 100644 apptentive-example/src/main/java/apptentive/com/app/AppFirebaseMessagingService.kt create mode 100644 apptentive-example/src/main/java/apptentive/com/app/DevFunctionsActivity.kt create mode 100644 apptentive-example/src/main/java/apptentive/com/app/FileItemAdapter.kt create mode 100644 apptentive-example/src/main/java/apptentive/com/app/FileUtils.kt create mode 100644 apptentive-example/src/main/java/apptentive/com/app/InfoUtil.kt create mode 100644 apptentive-example/src/main/java/apptentive/com/app/MainActivity.java create mode 100644 apptentive-example/src/main/java/apptentive/com/app/PermissionsUtils.kt create mode 100644 apptentive-example/src/main/res/drawable/cookbook_1_message_center_button_background.xml create mode 100644 apptentive-example/src/main/res/drawable/cookbook_1_message_center_compose_background.xml create mode 100644 apptentive-example/src/main/res/drawable/cookbook_1_message_center_toolbar_top_background.xml create mode 100644 apptentive-example/src/main/res/drawable/cookbook_2_message_center_compose_background.xml create mode 100644 apptentive-example/src/main/res/drawable/cookbook_3_message_center_compose_background.xml create mode 100644 apptentive-example/src/main/res/drawable/cookbook_3_message_center_toolbar_top.xml create mode 100644 apptentive-example/src/main/res/drawable/ic_apptentive_notification.xml create mode 100644 apptentive-example/src/main/res/drawable/note_example_background.xml create mode 100644 apptentive-example/src/main/res/drawable/note_example_header.png create mode 100644 apptentive-example/src/main/res/drawable/rating_header_example.png create mode 100644 apptentive-example/src/main/res/font/montserrat.xml create mode 100644 apptentive-example/src/main/res/layout/activity_dev_functions.xml create mode 100644 apptentive-example/src/main/res/layout/file_item.xml create mode 100644 apptentive-example/src/main/res/xml/provider_paths.xml create mode 100644 apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/engagement/MockEngagementContextFactory.kt create mode 100644 apptentive-feedback/src/main/java/apptentive/com/android/feedback/conversation/DefaultSerializers.kt create mode 100644 apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/InteractionResponseCriteria.kt create mode 100644 apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/interaction/SurveyQuestionSetConverter.kt create mode 100644 apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/QuestionListSubject.kt create mode 100644 apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyAnswerState.kt create mode 100644 apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyPageData.kt create mode 100644 apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyQuestionSet.kt create mode 100644 apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/view/SurveySegmentedProgressBar.kt create mode 100644 apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyIntroductionPageItem.kt create mode 100644 apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveySuccessPageItem.kt create mode 100644 apptentive-survey/src/main/res/layout/apptentive_survey_introduction.xml create mode 100644 apptentive-survey/src/main/res/layout/apptentive_survey_segmented_progress_bar.xml create mode 100644 apptentive-survey/src/main/res/layout/apptentive_survey_segmented_progress_bar_item.xml create mode 100644 apptentive-survey/src/main/res/layout/apptentive_survey_success_page.xml create mode 100644 apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/SurveyQuestionSetTest.kt create mode 100644 apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/TermsAndConditionsTest.kt create mode 100644 apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/viewmodel/SurveyInteractionResponseTest.kt create mode 100644 local-template.properties diff --git a/CHANGELOG.md b/CHANGELOG.md index b0edf564..8d96d2be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# 2023-07-20 - v6.1.0 +#### New Features +* Survey skip logic + +#### Improvements +* Survey terms and conditions can now be added from the dashboard +* Survey disclaimer can now be added from the dashboard +* Various accessibility improvements around Surveys +* Apptentive Logger service retrieval backup + +#### Fixes +* SparseArray backwards compatibility + +#### Known Issues and Limitations +* Client authentication (login/logout) is not yet supported + # 2023-05-17 - v6.0.5 #### Improvements * Added Java interoperable support for push functions @@ -7,6 +23,8 @@ #### Fixes * Resolved internal observers and observables issue +#### Known Issues and Limitations +* Client authentication (login/logout) is not yet supported # 2023-04-05 - v6.0.4 #### Improvements @@ -15,6 +33,9 @@ #### Fixes * Resolved resource linking issues +#### Known Issues and Limitations +* Client authentication (login/logout) is not yet supported + # 2023-02-24 - v6.0.3 #### Improvements * Support mParticleID collection diff --git a/Jenkinsfile b/Jenkinsfile-old similarity index 100% rename from Jenkinsfile rename to Jenkinsfile-old diff --git a/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ApptentivePagerAdapter.kt b/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ApptentivePagerAdapter.kt new file mode 100644 index 00000000..77d0d98f --- /dev/null +++ b/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ApptentivePagerAdapter.kt @@ -0,0 +1,49 @@ +package apptentive.com.android.ui + +import android.util.SparseArray +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import apptentive.com.android.util.InternalUseOnly + +@InternalUseOnly +class ApptentivePagerAdapter : RecyclerView.Adapter>() { + private val pages = mutableListOf() + private val viewHolderFactoryLookup = SparseArray() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ApptentiveViewHolder { + // resolve factory object + val viewHolderFactory = viewHolderFactoryLookup.get(viewType) + + // create item view + val itemView = viewHolderFactory.createItemView(parent) + + // create view holder + @Suppress("UNCHECKED_CAST") + return viewHolderFactory.createViewHolder(itemView) as ApptentiveViewHolder + } + + override fun onBindViewHolder(holder: ApptentiveViewHolder, position: Int) { + holder.bindView(pages[position], position) + } + + override fun getItemCount(): Int = pages.size + + override fun getItemViewType(position: Int): Int { + return pages[position].itemType + } + + fun register(itemType: Int, factory: ViewHolderFactory) { + viewHolderFactoryLookup.put(itemType, factory) + } + + fun addOrUpdatePage(page: ListViewItem, isFocusEditText: Boolean) { + val index = pages.indexOfFirst { it.id == page.id } + if (index == -1) { + pages.add(page) + notifyItemInserted(pages.size - 1) + } else { + pages[index] = page + if (!isFocusEditText) notifyItemChanged(index) + } + } +} diff --git a/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ApptentiveViewHolder.kt b/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ApptentiveViewHolder.kt new file mode 100644 index 00000000..000fca98 --- /dev/null +++ b/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ApptentiveViewHolder.kt @@ -0,0 +1,14 @@ +package apptentive.com.android.ui + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import apptentive.com.android.util.InternalUseOnly + +@InternalUseOnly +abstract class ApptentiveViewHolder constructor(itemView: View) : + RecyclerView.ViewHolder(itemView) { + abstract fun bindView(item: T, position: Int) + open fun updateView(item: T, position: Int, changeMask: Int) { + bindView(item, position) + } +} diff --git a/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ListViewAdapter.kt b/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ListViewAdapter.kt index ec930d54..e4a3854f 100644 --- a/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ListViewAdapter.kt +++ b/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ListViewAdapter.kt @@ -1,18 +1,16 @@ package apptentive.com.android.ui import android.util.SparseArray -import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView import apptentive.com.android.util.InternalUseOnly @InternalUseOnly -class ListViewAdapter : ListAdapter>(DIFF) { +class ListViewAdapter : ListAdapter>(DIFF) { private val viewHolderFactoryLookup = SparseArray() - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ApptentiveViewHolder { // resolve factory object val viewHolderFactory = viewHolderFactoryLookup.get(viewType) @@ -21,11 +19,11 @@ class ListViewAdapter : ListAdapter + return viewHolderFactory.createViewHolder(itemView) as ApptentiveViewHolder } override fun onBindViewHolder( - holder: ViewHolder, + holder: ApptentiveViewHolder, position: Int, payloads: MutableList ) { @@ -37,7 +35,7 @@ class ListViewAdapter : ListAdapter, position: Int) { + override fun onBindViewHolder(holder: ApptentiveViewHolder, position: Int) { holder.bindView(getItem(position), position) } @@ -46,15 +44,7 @@ class ListViewAdapter : ListAdapter constructor(itemView: View) : - RecyclerView.ViewHolder(itemView) { - abstract fun bindView(item: T, position: Int) - open fun updateView(item: T, position: Int, changeMask: Int) { - bindView(item, position) - } + viewHolderFactoryLookup.put(itemType, factory) } private companion object { diff --git a/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ViewHolderFactory.kt b/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ViewHolderFactory.kt index 5fc4a8de..38eb2138 100644 --- a/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ViewHolderFactory.kt +++ b/apptentive-core-ui/src/main/java/apptentive/com/android/ui/ViewHolderFactory.kt @@ -8,19 +8,19 @@ import apptentive.com.android.util.InternalUseOnly @InternalUseOnly interface ViewHolderFactory { fun createItemView(parent: ViewGroup): View - fun createViewHolder(itemView: View): ListViewAdapter.ViewHolder<*> + fun createViewHolder(itemView: View): ApptentiveViewHolder<*> } @InternalUseOnly class LayoutViewHolderFactory( private val layoutId: Int, - private val viewHolderCreator: (View) -> ListViewAdapter.ViewHolder<*> + private val viewHolderCreator: (View) -> ApptentiveViewHolder<*>, ) : ViewHolderFactory { override fun createItemView(parent: ViewGroup): View { return LayoutInflater.from(parent.context).inflate(layoutId, parent, false) } - override fun createViewHolder(itemView: View): ListViewAdapter.ViewHolder<*> { + override fun createViewHolder(itemView: View): ApptentiveViewHolder<*> { return viewHolderCreator.invoke(itemView) } } diff --git a/apptentive-core-ui/src/main/res/drawable/apptentive_custom_focus_overlay.xml b/apptentive-core-ui/src/main/res/drawable/apptentive_custom_focus_overlay.xml new file mode 100644 index 00000000..eb82d5ab --- /dev/null +++ b/apptentive-core-ui/src/main/res/drawable/apptentive_custom_focus_overlay.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/apptentive-core-ui/src/main/res/drawable/apptentive_pill_current.xml b/apptentive-core-ui/src/main/res/drawable/apptentive_pill_current.xml new file mode 100644 index 00000000..45e969dc --- /dev/null +++ b/apptentive-core-ui/src/main/res/drawable/apptentive_pill_current.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/apptentive-core-ui/src/main/res/drawable/apptentive_pill_next.xml b/apptentive-core-ui/src/main/res/drawable/apptentive_pill_next.xml new file mode 100644 index 00000000..9fdfd47e --- /dev/null +++ b/apptentive-core-ui/src/main/res/drawable/apptentive_pill_next.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/apptentive-core-ui/src/main/res/drawable/apptentive_pill_previous.xml b/apptentive-core-ui/src/main/res/drawable/apptentive_pill_previous.xml new file mode 100644 index 00000000..153e182f --- /dev/null +++ b/apptentive-core-ui/src/main/res/drawable/apptentive_pill_previous.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/apptentive-core-ui/src/main/res/values-night/colors.xml b/apptentive-core-ui/src/main/res/values-night/colors.xml index 536dcd47..2e5d4c6c 100644 --- a/apptentive-core-ui/src/main/res/values-night/colors.xml +++ b/apptentive-core-ui/src/main/res/values-night/colors.xml @@ -3,7 +3,7 @@ #40566B #E1E1E1 #94A8B8 - #E1E1E1 + #212121 #212121 #C4C4C4 #212121 diff --git a/apptentive-core-ui/src/main/res/values/apptentive-attrs.xml b/apptentive-core-ui/src/main/res/values/apptentive-attrs.xml index 7092fa6f..7d183c9e 100644 --- a/apptentive-core-ui/src/main/res/values/apptentive-attrs.xml +++ b/apptentive-core-ui/src/main/res/values/apptentive-attrs.xml @@ -61,6 +61,9 @@ + + + @@ -69,7 +72,9 @@ + + @@ -89,8 +94,17 @@ + + + + + + + + + @@ -143,6 +157,7 @@ + @@ -175,6 +190,7 @@ + @@ -205,6 +221,8 @@ + + @@ -224,10 +242,18 @@ + + + + + + + + @@ -250,6 +276,8 @@ + + diff --git a/apptentive-core-ui/src/main/res/values/colors.xml b/apptentive-core-ui/src/main/res/values/colors.xml index d191fc47..7680d75e 100644 --- a/apptentive-core-ui/src/main/res/values/colors.xml +++ b/apptentive-core-ui/src/main/res/values/colors.xml @@ -15,4 +15,7 @@ #00000000 #757575 #F5F5F5 + #6D6D6D + #AEAEAE + #80FFFFFF diff --git a/apptentive-core-ui/src/main/res/values/dimens.xml b/apptentive-core-ui/src/main/res/values/dimens.xml index c1bae1d8..14c2fb59 100644 --- a/apptentive-core-ui/src/main/res/values/dimens.xml +++ b/apptentive-core-ui/src/main/res/values/dimens.xml @@ -4,6 +4,8 @@ 14sp 18sp 20sp + 20sp + 16sp 16sp 12sp 10sp @@ -27,25 +29,36 @@ 16dp - 8dp - 24dp - 16dp + + + 8dp + 24dp + 24dp + 16dp + 16dp 8dp 4dp - 40dp 11dp 8dp + 4dp 40dp 2dp 4dp - 30dp 48dp 16dp + 24dp + 8dp 16dp 12dp 36dp - + 16dp + 24dp + 8dp + 12dp + 4dp + + 1dp 10dp 12dp diff --git a/apptentive-core-ui/src/main/res/values/styles.xml b/apptentive-core-ui/src/main/res/values/styles.xml index fe1c9882..72a4a6fb 100644 --- a/apptentive-core-ui/src/main/res/values/styles.xml +++ b/apptentive-core-ui/src/main/res/values/styles.xml @@ -71,8 +71,12 @@ @style/Apptentive.Survey.Layout + @style/Apptentive.Survey.Paged.Layout + @style/Apptentive.Survey.Layout.PageItem + @style/Apptentive.Survey.Layout.PageItem @style/Apptentive.Survey.Layout.Question @style/Apptentive.Empty + @style/Apptentive.Survey.Layout.Question.ScrollView @style/Apptentive.Widget.Survey.Toolbar @@ -81,13 +85,14 @@ @style/Apptentive.TextAppearance.Title.Survey @style/Apptentive.TextAppearance.Introduction.Survey + @style/Apptentive.TextAppearance.Success.Survey @style/Apptentive.TextAppearance.Title.Survey.Question @style/Apptentive.TextAppearance.Caption.Survey.Instructions @style/Apptentive.TextAppearance.Caption.Survey.Error @style/Apptentive.TextAppearance.Caption.Survey.RangeLabel @style/Apptentive.TextAppearance.Caption.Survey.TermsAndConditions @style/Apptentive.TextAppearance.Caption.Survey.Error.Footer - ?apptentiveSurveyFootnoteStyle + @style/Apptentive.TextAppearance.Caption.Survey.Disclaimer @style/Apptentive.TextAppearance.Caption.Survey.Disclaimer @@ -100,7 +105,16 @@ @style/Apptentive.Widget.Survey.OtherTextField.TextInputLayout @style/Apptentive.Widget.Survey.OtherTextField.EditText @style/Apptentive.Widget.Survey.Button.Submit + @style/Apptentive.Widget.Survey.Button.Submit.Paged ?colorPrimary + @style/Apptentive.Widget.Survey.ViewPager + @style/Apptentive.Widget.ProgressBar.Linear + @style/Apptentive.Widget.ProgressBar.Segmented + @style/Apptentive.Widget.ProgressBar.Segmented.Item + @drawable/apptentive_pill_previous + @drawable/apptentive_pill_current + @drawable/apptentive_pill_next + @drawable/apptentive_custom_focus_overlay @@ -115,6 +129,8 @@ @dimen/apptentive_text_size_question_title @dimen/apptentive_text_size_survey_introduction + @dimen/apptentive_text_size_survey_success + @dimen/apptentive_text_size_survey_disclaimer ?apptentiveTextSizeDefault @dimen/apptentive_text_size_caption ?apptentiveTextSizeDefault @@ -134,8 +150,9 @@ - + ?colorOnPrimary + @color/apptentive_color_focus_overlay ?colorOnSurface @@ -149,11 +166,19 @@ ?colorOnBackground ?colorOnPrimary ?colorOnBackground + ?colorOnBackground + ?colorOnBackground ?colorOnBackground ?colorOnBackground ?colorOnPrimary ?colorOnPrimary + ?colorPrimary + @color/apptentive_color_progress_bar_next + ?colorPrimary + @color/apptentive_color_progress_bar_previous + @color/apptentive_color_progress_bar_next + ?colorOnPrimary ?colorOnBackground @@ -224,6 +249,8 @@ ?apptentiveTypefaceDefault ?apptentiveTypefaceDefault ?apptentiveTypefaceDefault + ?apptentiveTypefaceDefault + ?apptentiveTypefaceDefault ?apptentiveTypefaceDefault ?apptentiveTypefaceDefault ?apptentiveTypefaceDefault @@ -326,14 +353,32 @@ ?android:colorBackground 1 androidx.recyclerview.widget.LinearLayoutManager + gone + + + + + + + @@ -404,9 +449,30 @@ ?apptentiveTypefaceSurveyIntroduction ?textAppearanceHeadline6 ?apptentiveHeaderAlpha + true + true + true center true - @dimen/apptentive_survey_introduction_padding + @dimen/apptentive_survey_introduction_success_padding + + + + + @@ -453,11 +518,12 @@ @@ -572,11 +639,12 @@ ?apptentiveFontFamilyBody1 ?apptentiveTypefaceBody1 @color/apptentive_mtrl_selection_control_button_tint - @dimen/apptentive_survey_radio_checkbox_min_height @dimen/apptentive_survey_radio_checkbox_range_horizontal_margin @dimen/apptentive_survey_radio_checkbox_range_horizontal_margin @dimen/apptentive_survey_radio_checkbox_horizontal_padding @dimen/apptentive_survey_radio_checkbox_horizontal_padding + @dimen/apptentive_survey_radio_checkbox_vertical_padding + @dimen/apptentive_survey_radio_checkbox_vertical_padding @@ -655,12 +723,44 @@ ?apptentiveTextSizeButtonColored @dimen/apptentive_survey_footers_vertical_padding_margin @dimen/apptentive_survey_footers_vertical_padding_margin + ?attr/apptentiveCustomFocusForegroundStyle + + + + + + + + + + + @@ -919,6 +1019,7 @@ ?apptentiveTextSizeButtonColored @dimen/apptentive_survey_footers_vertical_padding_margin @dimen/apptentive_survey_footers_vertical_padding_margin + ?apptentiveCustomFocusForegroundStyle --> @@ -255,6 +272,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -302,6 +339,7 @@ + @@ -319,6 +357,18 @@ + + + + + + + + + + + + @@ -452,6 +502,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -500,6 +572,7 @@ + @@ -517,6 +590,15 @@ + + + + + + + + + @@ -676,4 +758,19 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apptentive-example/src/main/res/xml/provider_paths.xml b/apptentive-example/src/main/res/xml/provider_paths.xml new file mode 100644 index 00000000..fafa14f8 --- /dev/null +++ b/apptentive-example/src/main/res/xml/provider_paths.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/engagement/MockEngagementContext.kt b/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/engagement/MockEngagementContext.kt index 7515f50a..3043709f 100644 --- a/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/engagement/MockEngagementContext.kt +++ b/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/engagement/MockEngagementContext.kt @@ -55,6 +55,18 @@ class MockEngagementContext( ): EngagementResult { return onInvoke?.invoke(invocations) ?: EngagementResult.InteractionNotShown("No runnable interactions") } + + override fun engageToRecordCurrentAnswer( + interactionResponses: Map>, + reset: Boolean + ) { + } + + override fun getNextQuestionSet( + invocations: List + ): String? { + return null + } }, payloadSender = MockPayloadSender(onSendPayload), executors = Executors(ImmediateExecutor, ImmediateExecutor) diff --git a/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/engagement/MockEngagementContextFactory.kt b/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/engagement/MockEngagementContextFactory.kt new file mode 100644 index 00000000..082a376b --- /dev/null +++ b/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/engagement/MockEngagementContextFactory.kt @@ -0,0 +1,14 @@ +package apptentive.com.android.feedback.engagement + +import apptentive.com.android.core.Provider + +class MockEngagementContextFactory(val getEngagementContext: () -> EngagementContext) : + Provider { + override fun get(): EngagementContextFactory { + return object : EngagementContextFactory { + override fun engagementContext(): EngagementContext { + return getEngagementContext() + } + } + } +} diff --git a/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/engagement/MockInteractionDataProvider.kt b/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/engagement/MockInteractionDataProvider.kt index 4cabe7c1..5d8c1343 100644 --- a/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/engagement/MockInteractionDataProvider.kt +++ b/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/engagement/MockInteractionDataProvider.kt @@ -17,4 +17,8 @@ class MockInteractionDataProvider : InteractionDataProvider { override fun getInteractionData(invocations: List): InteractionData? { TODO("Not yet implemented") } + + override fun getQuestionId(invocations: List): String? { + return null + } } diff --git a/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/testData.kt b/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/testData.kt index 096a525a..dabcedec 100644 --- a/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/testData.kt +++ b/apptentive-feedback-test/src/main/java/apptentive/com/android/feedback/testData.kt @@ -79,7 +79,7 @@ val mockDevice = Device( ) val mockSdk = SDK( - version = SDK_VERSION, + version = Constants.SDK_VERSION, platform = "Android", distribution = "Default", distributionVersion = SDK_VERSION, diff --git a/apptentive-feedback/consumer-rules.pro b/apptentive-feedback/consumer-rules.pro index 6dd59594..e3471009 100644 --- a/apptentive-feedback/consumer-rules.pro +++ b/apptentive-feedback/consumer-rules.pro @@ -1,5 +1,4 @@ # Preserve the line number information for debugging stack traces. -keepattributes SourceFile,LineNumberTable -keep class com.apptentive.android.sdk.** { *; } --keep class apptentive.com.android.feedback.model.** { *; } --keep class apptentive.com.android.feedback.engagement.interactions.InteractionData { *; } \ No newline at end of file +-keep class apptentive.com.android.feedback.** { *; } \ No newline at end of file diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/Apptentive.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/Apptentive.kt index ffc7de9b..400d7204 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/Apptentive.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/Apptentive.kt @@ -40,6 +40,7 @@ import apptentive.com.android.platform.SharedPrefConstants import apptentive.com.android.util.InternalUseOnly import apptentive.com.android.util.Log import apptentive.com.android.util.LogTags +import apptentive.com.android.util.LogTags.CONVERSATION import apptentive.com.android.util.LogTags.DEVICE import apptentive.com.android.util.LogTags.FEEDBACK import apptentive.com.android.util.LogTags.INTERACTIONS @@ -1267,4 +1268,26 @@ object Apptentive { } //endregion + + // internal + + @InternalUseOnly + fun setLocalManifest(json: String?): Boolean { + return try { + if (!json.isNullOrBlank()) { + stateExecutor.execute { + client.setLocalManifest(json) + } + true + } else { + Log.d(CONVERSATION, "json String was null or blank. URI: $json") + false + } + } catch (exception: Exception) { + Log.e(CONVERSATION, "Exception while setting local manifest as string", exception) + false + } + } + + //endregion } diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/ApptentiveClient.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/ApptentiveClient.kt index d2b88b62..2212786d 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/ApptentiveClient.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/ApptentiveClient.kt @@ -3,6 +3,7 @@ package apptentive.com.android.feedback import apptentive.com.android.feedback.engagement.Event import apptentive.com.android.util.Log import apptentive.com.android.util.LogTags.EVENT +import apptentive.com.android.util.LogTags.FEEDBACK import apptentive.com.android.util.LogTags.MESSAGE_CENTER import apptentive.com.android.util.LogTags.MESSAGE_CENTER_HIDDEN import apptentive.com.android.util.LogTags.PROFILE_DATA_GET @@ -25,6 +26,7 @@ internal interface ApptentiveClient { fun getPersonName(): String? fun getPersonEmail(): String? fun setPushIntegration(pushProvider: Int, token: String) + fun setLocalManifest(json: String) companion object { val NULL: ApptentiveClient = ApptentiveNullClient() @@ -104,4 +106,8 @@ private class ApptentiveNullClient : ApptentiveClient { Log.d(PROFILE_DATA_GET, "Apptentive SDK is not initialized; get person email failed") return null } + + override fun setLocalManifest(uri: String) { + Log.d(FEEDBACK, "Apptentive SDK is not initialized; set local manifest failed ") + } } diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/ApptentiveDefaultClient.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/ApptentiveDefaultClient.kt index 3b1112cb..d1154b30 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/ApptentiveDefaultClient.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/ApptentiveDefaultClient.kt @@ -206,7 +206,8 @@ class ApptentiveDefaultClient( interactionEngagement = createInteractionEngagement(), recordEvent = ::recordEvent, recordInteraction = ::recordInteraction, - recordInteractionResponses = ::recordInteractionResponses + recordInteractionResponses = ::recordInteractionResponses, + recordCurrentAnswer = ::recordCurrentAnswer ) // register engagement context as soon as DefaultEngagement is created to make it available for MessageManager DependencyProvider.register(EngagementContextProvider(engagement, payloadSender, executors)) @@ -526,6 +527,10 @@ class ApptentiveDefaultClient( ) } + override fun setLocalManifest(json: String) { + conversationManager.setTestManifestFromLocal(json) + } + @WorkerThread private fun recordInteraction(interaction: Interaction) { conversationManager.recordInteraction(interaction.id) @@ -536,6 +541,11 @@ class ApptentiveDefaultClient( conversationManager.recordInteractionResponses(interactionResponses) } + @WorkerThread + private fun recordCurrentAnswer(interactionResponses: Map>, reset: Boolean) { + conversationManager.recordCurrentResponse(interactionResponses, reset) + } + @WorkerThread private fun onPayloadSendFinish(result: Result) { when (result) { diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/Constants.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/Constants.kt index 2976975e..705dd955 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/Constants.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/Constants.kt @@ -4,8 +4,8 @@ import apptentive.com.android.util.InternalUseOnly @InternalUseOnly object Constants { - const val SDK_VERSION = "6.0.5" - const val API_VERSION = 11 + const val SDK_VERSION = "6.1.0" + const val API_VERSION = 12 const val SERVER_URL = "https://api.apptentive.com" const val REDACTED_DATA = "" private const val CONVERSATION_PATH = "/conversations/:conversation_id/" diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/conversation/ConversationManager.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/conversation/ConversationManager.kt index 3e7a422d..918a81db 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/conversation/ConversationManager.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/conversation/ConversationManager.kt @@ -2,6 +2,7 @@ package apptentive.com.android.feedback.conversation import androidx.annotation.WorkerThread import apptentive.com.android.core.BehaviorSubject +import apptentive.com.android.core.DependencyProvider import apptentive.com.android.core.Observable import apptentive.com.android.core.Provider import apptentive.com.android.core.isInThePast @@ -15,6 +16,7 @@ import apptentive.com.android.feedback.model.AppRelease import apptentive.com.android.feedback.model.Conversation import apptentive.com.android.feedback.model.Device import apptentive.com.android.feedback.model.EngagementData +import apptentive.com.android.feedback.model.EngagementManifest import apptentive.com.android.feedback.model.Person import apptentive.com.android.feedback.model.SDK import apptentive.com.android.feedback.model.VersionHistory @@ -24,6 +26,10 @@ import apptentive.com.android.feedback.utils.FileUtil import apptentive.com.android.feedback.utils.ThrottleUtils import apptentive.com.android.feedback.utils.VersionCode import apptentive.com.android.feedback.utils.VersionName +import apptentive.com.android.platform.AndroidSharedPrefDataStore +import apptentive.com.android.platform.SharedPrefConstants.SDK_CORE_INFO +import apptentive.com.android.platform.SharedPrefConstants.SDK_VERSION +import apptentive.com.android.serialization.json.JsonConverter import apptentive.com.android.util.Log import apptentive.com.android.util.LogTags.CONFIGURATION import apptentive.com.android.util.LogTags.CONVERSATION @@ -40,6 +46,7 @@ internal class ConversationManager( private val legacyConversationManagerProvider: Provider, private val isDebuggable: Boolean ) { + private var isUsingLocalManifest: Boolean = false private val activeConversationSubject: BehaviorSubject val activeConversation: Observable get() = activeConversationSubject private val sdkAppReleaseUpdateSubject = BehaviorSubject(false) @@ -49,6 +56,11 @@ internal class ConversationManager( init { val conversation = loadActiveConversation() + + // Store successful SDK version + DependencyProvider.of() + .putString(SDK_CORE_INFO, SDK_VERSION, Constants.SDK_VERSION) + activeConversationSubject = BehaviorSubject(conversation) } @@ -107,6 +119,10 @@ internal class ConversationManager( @Throws(ConversationSerializationException::class) @WorkerThread private fun loadActiveConversation(): Conversation { + // Added in 6.1.0. Previous versions will be `null`. + val storedSdkVersion = DependencyProvider.of() + .getString(SDK_CORE_INFO, SDK_VERSION).ifEmpty { null } + // load existing conversation val existingConversation = loadExistingConversation() if (existingConversation != null) { @@ -182,12 +198,25 @@ internal class ConversationManager( } } + fun setTestManifestFromLocal(json: String) { + if (isDebuggable) { + val data: EngagementManifest = + JsonConverter.fromJson(json, EngagementManifest::class.java) as EngagementManifest + Log.d(CONVERSATION, "Parsed engagement manifest $data") + activeConversationSubject.value = activeConversation.value.copy( + engagementManifest = data + ) + Log.d(CONVERSATION, "USING LOCALLY DOWNLOADED MANIFEST") + isUsingLocalManifest = true + } + } + @WorkerThread fun tryFetchEngagementManifest() { val conversation = activeConversationSubject.value val manifest = conversation.engagementManifest - if (isInThePast(manifest.expiry) || isDebuggable) { + if (isInThePast(manifest.expiry) || isDebuggable && !isUsingLocalManifest) { Log.d(CONVERSATION, "Fetching engagement manifest") val token = conversation.conversationToken val id = conversation.conversationId @@ -339,6 +368,25 @@ internal class ConversationManager( ) } + fun recordCurrentResponse(interactionResponses: Map>, reset: Boolean = false) { + val conversation = activeConversationSubject.value + activeConversationSubject.value = conversation.copy( + engagementData = conversation.engagementData.apply { + interactionResponses.forEach { responses -> + Log.v(INTERACTIONS, "Recording interaction responses ${responses.key to responses.value}") + updateCurrentAnswer( + interactionId = responses.key, + responses = responses.value, + versionName = conversation.appRelease.versionName, + versionCode = conversation.appRelease.versionCode, + lastInvoked = DateTime.now(), + reset = reset + ) + } + } + ) + } + internal fun loadExistingConversation(): Conversation? { return try { conversationRepository.loadConversation() diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/conversation/ConversationSerializer.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/conversation/ConversationSerializer.kt index 68754de2..3ed88ce9 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/conversation/ConversationSerializer.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/conversation/ConversationSerializer.kt @@ -1,48 +1,16 @@ package apptentive.com.android.feedback.conversation import androidx.core.util.AtomicFile +import apptentive.com.android.core.DependencyProvider import apptentive.com.android.core.TimeInterval import apptentive.com.android.encryption.Encryption -import apptentive.com.android.feedback.conversation.Serializers.conversationSerializer -import apptentive.com.android.feedback.engagement.Event -import apptentive.com.android.feedback.engagement.criteria.DateTime -import apptentive.com.android.feedback.engagement.interactions.InteractionId -import apptentive.com.android.feedback.engagement.interactions.InteractionResponse -import apptentive.com.android.feedback.engagement.interactions.InteractionResponseData -import apptentive.com.android.feedback.model.AppRelease -import apptentive.com.android.feedback.model.Configuration +import apptentive.com.android.feedback.conversation.DefaultSerializers.conversationSerializer import apptentive.com.android.feedback.model.Conversation -import apptentive.com.android.feedback.model.CustomData -import apptentive.com.android.feedback.model.Device -import apptentive.com.android.feedback.model.EngagementData import apptentive.com.android.feedback.model.EngagementManifest -import apptentive.com.android.feedback.model.EngagementRecord -import apptentive.com.android.feedback.model.EngagementRecords -import apptentive.com.android.feedback.model.IntegrationConfig -import apptentive.com.android.feedback.model.IntegrationConfigItem -import apptentive.com.android.feedback.model.Person -import apptentive.com.android.feedback.model.RandomSampling -import apptentive.com.android.feedback.model.SDK -import apptentive.com.android.feedback.model.VersionHistory -import apptentive.com.android.feedback.model.VersionHistoryItem +import apptentive.com.android.platform.AndroidSharedPrefDataStore +import apptentive.com.android.platform.SharedPrefConstants import apptentive.com.android.serialization.BinaryDecoder import apptentive.com.android.serialization.BinaryEncoder -import apptentive.com.android.serialization.Decoder -import apptentive.com.android.serialization.DoubleSerializer -import apptentive.com.android.serialization.Encoder -import apptentive.com.android.serialization.LongSerializer -import apptentive.com.android.serialization.StringSerializer -import apptentive.com.android.serialization.TypeDecoder -import apptentive.com.android.serialization.TypeEncoder -import apptentive.com.android.serialization.TypeSerializer -import apptentive.com.android.serialization.decodeList -import apptentive.com.android.serialization.decodeMap -import apptentive.com.android.serialization.decodeNullableString -import apptentive.com.android.serialization.decodeSet -import apptentive.com.android.serialization.encodeList -import apptentive.com.android.serialization.encodeMap -import apptentive.com.android.serialization.encodeNullableString -import apptentive.com.android.serialization.encodeSet import apptentive.com.android.serialization.json.JsonConverter import apptentive.com.android.util.Log import apptentive.com.android.util.LogTags.CONVERSATION @@ -50,7 +18,6 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.DataInputStream import java.io.DataOutputStream -import java.io.EOFException import java.io.File import java.io.FileInputStream @@ -116,7 +83,12 @@ internal class DefaultConversationSerializer( override fun loadConversation(): Conversation? { if (conversationFile.exists()) { val conversation = readConversation() - val engagementManifest = readEngagementManifest() + + // Added in 6.1.0. Previous versions will be `null`. + val storedSdkVersion = DependencyProvider.of() + .getString(SharedPrefConstants.SDK_CORE_INFO, SharedPrefConstants.SDK_VERSION).ifEmpty { null } + + val engagementManifest = if (storedSdkVersion != null) readEngagementManifest() else null if (engagementManifest != null) { return conversation.copy(engagementManifest = engagementManifest) } @@ -137,8 +109,6 @@ internal class DefaultConversationSerializer( val inputStream = ByteArrayInputStream(decryptedMessage) val decoder = BinaryDecoder(DataInputStream(inputStream)) conversationSerializer.decode((decoder)) - } catch (e: EOFException) { - throw ConversationSerializationException("Unable to load conversation: file corrupted", e) } catch (e: Exception) { throw ConversationSerializationException("Unable to load conversation", e) } @@ -155,511 +125,3 @@ internal class DefaultConversationSerializer( return null } } - -internal object Serializers { - val versionCodeSerializer = LongSerializer - - val versionNameSerializer = StringSerializer - - val interactionIdSerializer = StringSerializer - - val dateTimeSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: DateTime) { - encoder.encodeDouble(value.seconds) - } - - override fun decode(decoder: Decoder): DateTime { - return DateTime(seconds = decoder.decodeDouble()) - } - } - } - - val customDataSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: CustomData) { - encoder.encodeMap(value.content) - } - - override fun decode(decoder: Decoder) = CustomData(content = decoder.decodeMap()) - } - } - - val deviceSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: Device) { - encoder.encodeString(value.osName) - encoder.encodeString(value.osVersion) - encoder.encodeString(value.osBuild) - encoder.encodeInt(value.osApiLevel) - encoder.encodeString(value.manufacturer) - encoder.encodeString(value.model) - encoder.encodeString(value.board) - encoder.encodeString(value.product) - encoder.encodeString(value.brand) - encoder.encodeString(value.cpu) - encoder.encodeString(value.device) - encoder.encodeString(value.uuid) - encoder.encodeString(value.buildType) - encoder.encodeString(value.buildId) - encoder.encodeNullableString(value.carrier) - encoder.encodeNullableString(value.currentCarrier) - encoder.encodeNullableString(value.networkType) - encoder.encodeNullableString(value.bootloaderVersion) - encoder.encodeNullableString(value.radioVersion) - encoder.encodeString(value.localeCountryCode) - encoder.encodeString(value.localeLanguageCode) - encoder.encodeString(value.localeRaw) - encoder.encodeInt(value.utcOffset) - customDataSerializer.encode(encoder, value.customData) - encodeIntegrationConfigItem(encoder, value) - } - - private fun encodeIntegrationConfigItem(encoder: Encoder, value: Device) { - encodeNullableIntegrationConfigItem(encoder, value.integrationConfig.apptentive) - encodeNullableIntegrationConfigItem(encoder, value.integrationConfig.amazonAwsSns) - encodeNullableIntegrationConfigItem(encoder, value.integrationConfig.urbanAirship) - encodeNullableIntegrationConfigItem(encoder, value.integrationConfig.parse) - } - - private fun encodeNullableIntegrationConfigItem( - encoder: Encoder, - obj: IntegrationConfigItem? - ) { - encoder.encodeBoolean(obj != null) - if (obj != null) { - encodeIntegrationConfigItem(encoder, obj) - } - } - - private fun encodeIntegrationConfigItem(encoder: Encoder, obj: IntegrationConfigItem) { - encoder.encodeMap(obj.contents) - } - - override fun decode(decoder: Decoder): Device { - return Device( - osName = decoder.decodeString(), - osVersion = decoder.decodeString(), - osBuild = decoder.decodeString(), - osApiLevel = decoder.decodeInt(), - manufacturer = decoder.decodeString(), - model = decoder.decodeString(), - board = decoder.decodeString(), - product = decoder.decodeString(), - brand = decoder.decodeString(), - cpu = decoder.decodeString(), - device = decoder.decodeString(), - uuid = decoder.decodeString(), - buildType = decoder.decodeString(), - buildId = decoder.decodeString(), - carrier = decoder.decodeNullableString(), - currentCarrier = decoder.decodeNullableString(), - networkType = decoder.decodeNullableString(), - bootloaderVersion = decoder.decodeNullableString(), - radioVersion = decoder.decodeNullableString(), - localeCountryCode = decoder.decodeString(), - localeLanguageCode = decoder.decodeString(), - localeRaw = decoder.decodeString(), - utcOffset = decoder.decodeInt(), - customData = customDataSerializer.decode(decoder), - integrationConfig = decodeIntegrationConfig(decoder) - ) - } - - private fun decodeIntegrationConfig(decoder: Decoder) = - IntegrationConfig( - apptentive = decodeNullableIntegrationConfigItem(decoder), - amazonAwsSns = decodeNullableIntegrationConfigItem(decoder), - urbanAirship = decodeNullableIntegrationConfigItem(decoder), - parse = decodeNullableIntegrationConfigItem(decoder) - ) - - private fun decodeNullableIntegrationConfigItem(decoder: Decoder) = - if (decoder.decodeBoolean()) decodeIntegrationConfigItem(decoder) else null - - private fun decodeIntegrationConfigItem(decoder: Decoder) = - IntegrationConfigItem(contents = decoder.decodeMap()) - } - } - - val personSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: Person) { - encoder.encodeNullableString(value.id) - encoder.encodeNullableString(value.email) - encoder.encodeNullableString(value.name) - encoder.encodeNullableString(value.mParticleId) - customDataSerializer.encode(encoder, value.customData) - } - - override fun decode(decoder: Decoder): Person { - return Person( - id = decoder.decodeNullableString(), - email = decoder.decodeNullableString(), - name = decoder.decodeNullableString(), - mParticleId = decoder.decodeNullableString(), - customData = customDataSerializer.decode(decoder) - ) - } - } - } - - val sdkSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: SDK) { - encoder.encodeString(value.version) - encoder.encodeString(value.platform) - encoder.encodeNullableString(value.distribution) - encoder.encodeNullableString(value.distributionVersion) - encoder.encodeNullableString(value.programmingLanguage) - encoder.encodeNullableString(value.authorName) - encoder.encodeNullableString(value.authorEmail) - } - - override fun decode(decoder: Decoder): SDK { - return SDK( - version = decoder.decodeString(), - platform = decoder.decodeString(), - distribution = decoder.decodeNullableString(), - distributionVersion = decoder.decodeNullableString(), - programmingLanguage = decoder.decodeNullableString(), - authorName = decoder.decodeNullableString(), - authorEmail = decoder.decodeNullableString() - ) - } - } - } - - val appReleaseSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: AppRelease) { - encoder.encodeString(value.type) - encoder.encodeString(value.identifier) - encoder.encodeLong(value.versionCode) - encoder.encodeString(value.versionName) - encoder.encodeString(value.targetSdkVersion) - encoder.encodeString(value.minSdkVersion) - encoder.encodeBoolean(value.debug) - encoder.encodeBoolean(value.inheritStyle) - encoder.encodeBoolean(value.overrideStyle) - encoder.encodeNullableString(value.appStore) - encoder.encodeNullableString(value.customAppStoreURL) - } - - override fun decode(decoder: Decoder): AppRelease { - return AppRelease( - type = decoder.decodeString(), - identifier = decoder.decodeString(), - versionCode = decoder.decodeLong(), - versionName = decoder.decodeString(), - targetSdkVersion = decoder.decodeString(), - minSdkVersion = decoder.decodeString(), - debug = decoder.decodeBoolean(), - inheritStyle = decoder.decodeBoolean(), - overrideStyle = decoder.decodeBoolean(), - appStore = decoder.decodeNullableString(), - customAppStoreURL = decoder.decodeNullableString() - ) - } - } - } - - val configurationSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: Configuration) { - encoder.encodeDouble(value.expiry) - messageCenterConfigurationSerializer.encode(encoder, value.messageCenter) - } - - override fun decode(decoder: Decoder): Configuration { - return Configuration( - expiry = decoder.decodeDouble(), - messageCenter = messageCenterConfigurationSerializer.decode(decoder) - ) - } - } - } - - val messageCenterConfigurationSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: Configuration.MessageCenter) { - encoder.encodeDouble(value.fgPoll) - encoder.encodeDouble(value.bgPoll) - } - - override fun decode(decoder: Decoder): Configuration.MessageCenter { - return Configuration.MessageCenter( - fgPoll = decoder.decodeDouble(), - bgPoll = decoder.decodeDouble() - ) - } - } - } - - val randomSamplingSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: RandomSampling) { - encoder.encodeMap( - obj = value.percents, - keyEncoder = interactionIdSerializer, - valueEncoder = DoubleSerializer - ) - } - - override fun decode(decoder: Decoder): RandomSampling { - return RandomSampling( - percents = decoder.decodeMap( - keyDecoder = interactionIdSerializer, - valueDecoder = DoubleSerializer - ) - ) - } - } - } - - val engagementRecordSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: EngagementRecord) { - encoder.encodeLong(value.getTotalInvokes()) - encoder.encodeMap( - obj = value.versionCodes, - keyEncoder = versionCodeSerializer, - valueEncoder = LongSerializer - ) - encoder.encodeMap( - obj = value.versionNames, - keyEncoder = versionNameSerializer, - valueEncoder = LongSerializer - ) - dateTimeSerializer.encode(encoder, value.getLastInvoked()) - } - - override fun decode(decoder: Decoder): EngagementRecord { - return EngagementRecord( - totalInvokes = decoder.decodeLong(), - versionCodeLookup = decoder.decodeMap( - keyDecoder = versionCodeSerializer, - valueDecoder = LongSerializer - ), - versionNameLookup = decoder.decodeMap( - keyDecoder = versionNameSerializer, - valueDecoder = LongSerializer - ), - lastInvoked = dateTimeSerializer.decode(decoder) - ) - } - } - } - - val eventSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: Event) = - encoder.encodeString(value.fullName) - - override fun decode(decoder: Decoder) = Event.parse(decoder.decodeString()) - } - } - - val interactionResponseDataSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: InteractionResponseData) { - encoder.encodeSet( - obj = value.responses, - valueEncoder = interactionResponseSerializer - ) - engagementRecordSerializer.encode(encoder, value.record) - } - - override fun decode(decoder: Decoder): InteractionResponseData { - return InteractionResponseData( - responses = decoder.decodeSet(interactionResponseSerializer), - record = engagementRecordSerializer.decode(decoder) - ) - } - } - } - - val interactionResponseSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: InteractionResponse) { - val responseName = value::class.java.name - encoder.encodeString(responseName) - - when (responseName) { - InteractionResponse.IdResponse::class.java.name -> { - value as InteractionResponse.IdResponse - encoder.encodeString(value.id) - } - InteractionResponse.LongResponse::class.java.name -> { - value as InteractionResponse.LongResponse - encoder.encodeLong(value.response) - } - InteractionResponse.StringResponse::class.java.name -> { - value as InteractionResponse.StringResponse - encoder.encodeString(value.response) - } - InteractionResponse.OtherResponse::class.java.name -> { - value as InteractionResponse.OtherResponse - encoder.encodeNullableString(value.id) - encoder.encodeNullableString(value.response) - } - } - } - - override fun decode(decoder: Decoder): InteractionResponse { - val responseName = decoder.decodeString() - - return when (responseName) { - InteractionResponse.IdResponse::class.java.name -> { - InteractionResponse.IdResponse(decoder.decodeString()) - } - InteractionResponse.LongResponse::class.java.name -> { - InteractionResponse.LongResponse(decoder.decodeLong()) - } - InteractionResponse.StringResponse::class.java.name -> { - InteractionResponse.StringResponse(decoder.decodeString()) - } - InteractionResponse.OtherResponse::class.java.name -> { - InteractionResponse.OtherResponse( - id = decoder.decodeNullableString(), - response = decoder.decodeNullableString() - ) - } - else -> throw java.lang.Exception("Unknown InteractionResponse type: $responseName") - } - } - } - } - - val engagementDataSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: EngagementData) { - encodeEventData(encoder, value.events) - encodeInteractionData(encoder, value.interactions) - encodeInteractionResponsesData(encoder, value.interactionResponses) - encodeVersionHistory(encoder, value.versionHistory) - } - - private fun encodeEventData(encoder: Encoder, events: EngagementRecords) = - encodeEngagementRecords( - encoder = encoder, - obj = events, - keyEncoder = eventSerializer - ) - - private fun encodeInteractionData(encoder: Encoder, interactions: EngagementRecords) = - encodeEngagementRecords( - encoder = encoder, - obj = interactions, - keyEncoder = interactionIdSerializer - ) - - private fun encodeInteractionResponsesData( - encoder: Encoder, - interactionResponses: Map - ) { - encoder.encodeMap( - obj = interactionResponses, - keyEncoder = interactionIdSerializer, - valueEncoder = interactionResponseDataSerializer - ) - } - - private fun encodeEngagementRecords( - encoder: Encoder, - obj: EngagementRecords, - keyEncoder: TypeEncoder - ) { - encoder.encodeMap( - obj = obj.records, - keyEncoder = keyEncoder, - valueEncoder = engagementRecordSerializer - ) - } - - private fun encodeVersionHistory(encoder: Encoder, versionHistory: VersionHistory) = - encoder.encodeList(versionHistory.items) { item -> - encodeDouble(item.timestamp) - encodeLong(item.versionCode) - encodeString(item.versionName) - } - - override fun decode(decoder: Decoder): EngagementData { - return EngagementData( - events = decodeEventRecords(decoder), - interactions = decodeInteractionRecords(decoder), - interactionResponses = decodeInteractionResponsesRecords(decoder), - versionHistory = decodeVersionHistory(decoder) - ) - } - - private fun decodeEventRecords(decoder: Decoder): EngagementRecords { - return decodeEngagementRecords(decoder, keyDecoder = eventSerializer) - } - - private fun decodeInteractionRecords(decoder: Decoder): EngagementRecords { - return decodeEngagementRecords(decoder, keyDecoder = interactionIdSerializer) - } - - private fun decodeInteractionResponsesRecords(decoder: Decoder): MutableMap { - return decoder.decodeMap( - keyDecoder = interactionIdSerializer, - valueDecoder = interactionResponseDataSerializer - ) - } - - private fun decodeEngagementRecords( - decoder: Decoder, - keyDecoder: TypeDecoder - ) = EngagementRecords( - records = decoder.decodeMap( - keyDecoder = keyDecoder, - valueDecoder = engagementRecordSerializer - ) - ) - - private fun decodeVersionHistory(decoder: Decoder) = VersionHistory( - items = decoder.decodeList { - VersionHistoryItem( - timestamp = decodeDouble(), - versionCode = decodeLong(), - versionName = decodeString() - ) - } - ) - } - } - - val conversationSerializer: TypeSerializer by lazy { - object : TypeSerializer { - override fun encode(encoder: Encoder, value: Conversation) { - encoder.encodeString(value.localIdentifier) - encoder.encodeNullableString(value.conversationToken) - encoder.encodeNullableString(value.conversationId) - deviceSerializer.encode(encoder, value.device) - personSerializer.encode(encoder, value.person) - sdkSerializer.encode(encoder, value.sdk) - appReleaseSerializer.encode(encoder, value.appRelease) - configurationSerializer.encode(encoder, value.configuration) - randomSamplingSerializer.encode(encoder, value.randomSampling) - engagementDataSerializer.encode(encoder, value.engagementData) - } - - override fun decode(decoder: Decoder): Conversation { - return Conversation( - localIdentifier = decoder.decodeString(), - conversationToken = decoder.decodeNullableString(), - conversationId = decoder.decodeNullableString(), - device = deviceSerializer.decode(decoder), - person = personSerializer.decode(decoder), - sdk = sdkSerializer.decode(decoder), - appRelease = appReleaseSerializer.decode(decoder), - configuration = configurationSerializer.decode(decoder), - randomSampling = randomSamplingSerializer.decode(decoder), - engagementManifest = EngagementManifest(), // EngagementManifest is serialized separately - engagementData = engagementDataSerializer.decode(decoder) - ) - } - } - } -} diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/conversation/DefaultSerializers.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/conversation/DefaultSerializers.kt new file mode 100644 index 00000000..a9608af5 --- /dev/null +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/conversation/DefaultSerializers.kt @@ -0,0 +1,588 @@ +package apptentive.com.android.feedback.conversation + +import apptentive.com.android.feedback.engagement.Event +import apptentive.com.android.feedback.engagement.criteria.DateTime +import apptentive.com.android.feedback.engagement.interactions.InteractionId +import apptentive.com.android.feedback.engagement.interactions.InteractionResponse +import apptentive.com.android.feedback.engagement.interactions.InteractionResponseData +import apptentive.com.android.feedback.model.AppRelease +import apptentive.com.android.feedback.model.Configuration +import apptentive.com.android.feedback.model.Conversation +import apptentive.com.android.feedback.model.CustomData +import apptentive.com.android.feedback.model.Device +import apptentive.com.android.feedback.model.EngagementData +import apptentive.com.android.feedback.model.EngagementManifest +import apptentive.com.android.feedback.model.EngagementRecord +import apptentive.com.android.feedback.model.EngagementRecords +import apptentive.com.android.feedback.model.IntegrationConfig +import apptentive.com.android.feedback.model.IntegrationConfigItem +import apptentive.com.android.feedback.model.Person +import apptentive.com.android.feedback.model.RandomSampling +import apptentive.com.android.feedback.model.SDK +import apptentive.com.android.feedback.model.VersionHistory +import apptentive.com.android.feedback.model.VersionHistoryItem +import apptentive.com.android.serialization.Decoder +import apptentive.com.android.serialization.DoubleSerializer +import apptentive.com.android.serialization.Encoder +import apptentive.com.android.serialization.LongSerializer +import apptentive.com.android.serialization.StringSerializer +import apptentive.com.android.serialization.TypeDecoder +import apptentive.com.android.serialization.TypeEncoder +import apptentive.com.android.serialization.TypeSerializer +import apptentive.com.android.serialization.decodeList +import apptentive.com.android.serialization.decodeMap +import apptentive.com.android.serialization.decodeNullableString +import apptentive.com.android.serialization.decodeSet +import apptentive.com.android.serialization.encodeList +import apptentive.com.android.serialization.encodeMap +import apptentive.com.android.serialization.encodeNullableString +import apptentive.com.android.serialization.encodeSet +import apptentive.com.android.util.Log +import apptentive.com.android.util.LogTags.MIGRATION + +internal object DefaultSerializers { + val versionCodeSerializer = LongSerializer + + val versionNameSerializer = StringSerializer + + val interactionIdSerializer = StringSerializer + + val dateTimeSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: DateTime) { + encoder.encodeDouble(value.seconds) + } + + override fun decode(decoder: Decoder): DateTime { + return DateTime(seconds = decoder.decodeDouble()) + } + } + } + + val customDataSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: CustomData) { + encoder.encodeMap(value.content) + } + + override fun decode(decoder: Decoder) = CustomData(content = decoder.decodeMap()) + } + } + + val deviceSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: Device) { + encoder.encodeString(value.osName) + encoder.encodeString(value.osVersion) + encoder.encodeString(value.osBuild) + encoder.encodeInt(value.osApiLevel) + encoder.encodeString(value.manufacturer) + encoder.encodeString(value.model) + encoder.encodeString(value.board) + encoder.encodeString(value.product) + encoder.encodeString(value.brand) + encoder.encodeString(value.cpu) + encoder.encodeString(value.device) + encoder.encodeString(value.uuid) + encoder.encodeString(value.buildType) + encoder.encodeString(value.buildId) + encoder.encodeNullableString(value.carrier) + encoder.encodeNullableString(value.currentCarrier) + encoder.encodeNullableString(value.networkType) + encoder.encodeNullableString(value.bootloaderVersion) + encoder.encodeNullableString(value.radioVersion) + encoder.encodeString(value.localeCountryCode) + encoder.encodeString(value.localeLanguageCode) + encoder.encodeString(value.localeRaw) + encoder.encodeInt(value.utcOffset) + customDataSerializer.encode(encoder, value.customData) + encodeIntegrationConfigItem(encoder, value) + } + + private fun encodeIntegrationConfigItem(encoder: Encoder, value: Device) { + encodeNullableIntegrationConfigItem(encoder, value.integrationConfig.apptentive) + encodeNullableIntegrationConfigItem(encoder, value.integrationConfig.amazonAwsSns) + encodeNullableIntegrationConfigItem(encoder, value.integrationConfig.urbanAirship) + encodeNullableIntegrationConfigItem(encoder, value.integrationConfig.parse) + } + + private fun encodeNullableIntegrationConfigItem( + encoder: Encoder, + obj: IntegrationConfigItem? + ) { + encoder.encodeBoolean(obj != null) + if (obj != null) { + encodeIntegrationConfigItem(encoder, obj) + } + } + + private fun encodeIntegrationConfigItem(encoder: Encoder, obj: IntegrationConfigItem) { + encoder.encodeMap(obj.contents) + } + + override fun decode(decoder: Decoder): Device { + return Device( + osName = decoder.decodeString(), + osVersion = decoder.decodeString(), + osBuild = decoder.decodeString(), + osApiLevel = decoder.decodeInt(), + manufacturer = decoder.decodeString(), + model = decoder.decodeString(), + board = decoder.decodeString(), + product = decoder.decodeString(), + brand = decoder.decodeString(), + cpu = decoder.decodeString(), + device = decoder.decodeString(), + uuid = decoder.decodeString(), + buildType = decoder.decodeString(), + buildId = decoder.decodeString(), + carrier = decoder.decodeNullableString(), + currentCarrier = decoder.decodeNullableString(), + networkType = decoder.decodeNullableString(), + bootloaderVersion = decoder.decodeNullableString(), + radioVersion = decoder.decodeNullableString(), + localeCountryCode = decoder.decodeString(), + localeLanguageCode = decoder.decodeString(), + localeRaw = decoder.decodeString(), + utcOffset = decoder.decodeInt(), + customData = customDataSerializer.decode(decoder), + integrationConfig = decodeIntegrationConfig(decoder) + ) + } + + private fun decodeIntegrationConfig(decoder: Decoder) = + IntegrationConfig( + apptentive = decodeNullableIntegrationConfigItem(decoder), + amazonAwsSns = decodeNullableIntegrationConfigItem(decoder), + urbanAirship = decodeNullableIntegrationConfigItem(decoder), + parse = decodeNullableIntegrationConfigItem(decoder) + ) + + private fun decodeNullableIntegrationConfigItem(decoder: Decoder) = + if (decoder.decodeBoolean()) decodeIntegrationConfigItem(decoder) else null + + private fun decodeIntegrationConfigItem(decoder: Decoder) = + IntegrationConfigItem(contents = decoder.decodeMap()) + } + } + + val personSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: Person) { + encoder.encodeNullableString(value.id) + encoder.encodeNullableString(value.email) + encoder.encodeNullableString(value.name) + encoder.encodeNullableString(value.mParticleId) + customDataSerializer.encode(encoder, value.customData) + } + + override fun decode(decoder: Decoder): Person { + return Person( + id = decoder.decodeNullableString(), + email = decoder.decodeNullableString(), + name = decoder.decodeNullableString(), + mParticleId = decoder.decodeNullableString(), + customData = customDataSerializer.decode(decoder) + ) + } + } + } + + val sdkSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: SDK) { + encoder.encodeString(value.version) + encoder.encodeString(value.platform) + encoder.encodeNullableString(value.distribution) + encoder.encodeNullableString(value.distributionVersion) + encoder.encodeNullableString(value.programmingLanguage) + encoder.encodeNullableString(value.authorName) + encoder.encodeNullableString(value.authorEmail) + } + + override fun decode(decoder: Decoder): SDK { + return SDK( + version = decoder.decodeString(), + platform = decoder.decodeString(), + distribution = decoder.decodeNullableString(), + distributionVersion = decoder.decodeNullableString(), + programmingLanguage = decoder.decodeNullableString(), + authorName = decoder.decodeNullableString(), + authorEmail = decoder.decodeNullableString() + ) + } + } + } + + val appReleaseSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: AppRelease) { + encoder.encodeString(value.type) + encoder.encodeString(value.identifier) + encoder.encodeLong(value.versionCode) + encoder.encodeString(value.versionName) + encoder.encodeString(value.targetSdkVersion) + encoder.encodeString(value.minSdkVersion) + encoder.encodeBoolean(value.debug) + encoder.encodeBoolean(value.inheritStyle) + encoder.encodeBoolean(value.overrideStyle) + encoder.encodeNullableString(value.appStore) + encoder.encodeNullableString(value.customAppStoreURL) + } + + override fun decode(decoder: Decoder): AppRelease { + return AppRelease( + type = decoder.decodeString(), + identifier = decoder.decodeString(), + versionCode = decoder.decodeLong(), + versionName = decoder.decodeString(), + targetSdkVersion = decoder.decodeString(), + minSdkVersion = decoder.decodeString(), + debug = decoder.decodeBoolean(), + inheritStyle = decoder.decodeBoolean(), + overrideStyle = decoder.decodeBoolean(), + appStore = decoder.decodeNullableString(), + customAppStoreURL = decoder.decodeNullableString() + ) + } + } + } + + val configurationSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: Configuration) { + encoder.encodeDouble(value.expiry) + messageCenterConfigurationSerializer.encode(encoder, value.messageCenter) + } + + override fun decode(decoder: Decoder): Configuration { + return Configuration( + expiry = decoder.decodeDouble(), + messageCenter = messageCenterConfigurationSerializer.decode(decoder) + ) + } + } + } + + val messageCenterConfigurationSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: Configuration.MessageCenter) { + encoder.encodeDouble(value.fgPoll) + encoder.encodeDouble(value.bgPoll) + } + + override fun decode(decoder: Decoder): Configuration.MessageCenter { + return Configuration.MessageCenter( + fgPoll = decoder.decodeDouble(), + bgPoll = decoder.decodeDouble() + ) + } + } + } + + val randomSamplingSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: RandomSampling) { + encoder.encodeMap( + obj = value.percents, + keyEncoder = interactionIdSerializer, + valueEncoder = DoubleSerializer + ) + } + + override fun decode(decoder: Decoder): RandomSampling { + return RandomSampling( + percents = decoder.decodeMap( + keyDecoder = interactionIdSerializer, + valueDecoder = DoubleSerializer + ) + ) + } + } + } + + val engagementRecordSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: EngagementRecord) { + encoder.encodeLong(value.getTotalInvokes()) + encoder.encodeMap( + obj = value.versionCodes, + keyEncoder = versionCodeSerializer, + valueEncoder = LongSerializer + ) + encoder.encodeMap( + obj = value.versionNames, + keyEncoder = versionNameSerializer, + valueEncoder = LongSerializer + ) + dateTimeSerializer.encode(encoder, value.getLastInvoked()) + } + + override fun decode(decoder: Decoder): EngagementRecord { + return EngagementRecord( + totalInvokes = decoder.decodeLong(), + versionCodeLookup = decoder.decodeMap( + keyDecoder = versionCodeSerializer, + valueDecoder = LongSerializer + ), + versionNameLookup = decoder.decodeMap( + keyDecoder = versionNameSerializer, + valueDecoder = LongSerializer + ), + lastInvoked = dateTimeSerializer.decode(decoder) + ) + } + } + } + + val eventSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: Event) = + encoder.encodeString(value.fullName) + + override fun decode(decoder: Decoder) = Event.parse(decoder.decodeString()) + } + } + + val interactionResponseDataSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: InteractionResponseData) { + encoder.encodeSet( + obj = value.responses, + valueEncoder = interactionResponseSerializer + ) + engagementRecordSerializer.encode(encoder, value.record) + } + + override fun decode(decoder: Decoder): InteractionResponseData { + return InteractionResponseData( + responses = decoder.decodeSet(interactionResponseSerializer), + record = engagementRecordSerializer.decode(decoder), + ) + } + } + } + + val interactionResponseSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: InteractionResponse) { + val responseName = value::class.java.name + encoder.encodeString(responseName) + + when (responseName) { + InteractionResponse.IdResponse::class.java.name -> { + value as InteractionResponse.IdResponse + encoder.encodeString(value.id) + } + InteractionResponse.LongResponse::class.java.name -> { + value as InteractionResponse.LongResponse + encoder.encodeLong(value.response) + } + InteractionResponse.StringResponse::class.java.name -> { + value as InteractionResponse.StringResponse + encoder.encodeString(value.response) + } + InteractionResponse.OtherResponse::class.java.name -> { + value as InteractionResponse.OtherResponse + encoder.encodeNullableString(value.id) + encoder.encodeNullableString(value.response) + } + } + } + + override fun decode(decoder: Decoder): InteractionResponse { + val responseName = decoder.decodeString() + val recoveredResponse = recoverResponse(responseName) + + return when { + responseName == InteractionResponse.IdResponse::class.java.name || + recoveredResponse == InteractionResponse.IdResponse::class.java.name -> { + InteractionResponse.IdResponse(decoder.decodeString()) + } + responseName == InteractionResponse.LongResponse::class.java.name || + recoveredResponse == InteractionResponse.LongResponse::class.java.name -> { + InteractionResponse.LongResponse(decoder.decodeLong()) + } + responseName == InteractionResponse.StringResponse::class.java.name || + recoveredResponse == InteractionResponse.StringResponse::class.java.name -> { + InteractionResponse.StringResponse(decoder.decodeString()) + } + responseName == InteractionResponse.OtherResponse::class.java.name || + recoveredResponse == InteractionResponse.OtherResponse::class.java.name -> { + InteractionResponse.OtherResponse( + id = decoder.decodeNullableString(), + response = decoder.decodeNullableString() + ) + } + else -> throw java.lang.Exception("Unknown InteractionResponse type: $responseName") + } + } + + // Class names from 6.0.X are not saved from minification. + // This is a backup to handle that case assuming the class names are in the same order. + private fun recoverResponse(responseName: String): String = when (responseName.last()) { + 'a' -> { + Log.d(MIGRATION, "Decoding interaction response: $responseName. Recovered as IdResponse") + InteractionResponse.IdResponse::class.java.name + } + + 'b' -> { + Log.d(MIGRATION, "Decoding interaction response: $responseName. Recovered as LongResponse") + InteractionResponse.LongResponse::class.java.name + } + + 'd' -> { + Log.d(MIGRATION, "Decoding interaction response: $responseName. Recovered as StringResponse") + InteractionResponse.StringResponse::class.java.name + } + + 'c' -> { + Log.d(MIGRATION, "Decoding interaction response: $responseName. Recovered as OtherResponse") + InteractionResponse.OtherResponse::class.java.name + } + + else -> "Unknown or Backup not needed" + } + } + } + + val engagementDataSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: EngagementData) { + encodeEventData(encoder, value.events) + encodeInteractionData(encoder, value.interactions) + encodeInteractionResponsesData(encoder, value.interactionResponses) + encodeVersionHistory(encoder, value.versionHistory) + } + + private fun encodeEventData(encoder: Encoder, events: EngagementRecords) = + encodeEngagementRecords( + encoder = encoder, + obj = events, + keyEncoder = eventSerializer + ) + + private fun encodeInteractionData(encoder: Encoder, interactions: EngagementRecords) = + encodeEngagementRecords( + encoder = encoder, + obj = interactions, + keyEncoder = interactionIdSerializer + ) + + private fun encodeInteractionResponsesData( + encoder: Encoder, + interactionResponses: Map + ) { + encoder.encodeMap( + obj = interactionResponses, + keyEncoder = interactionIdSerializer, + valueEncoder = interactionResponseDataSerializer + ) + } + + private fun encodeEngagementRecords( + encoder: Encoder, + obj: EngagementRecords, + keyEncoder: TypeEncoder + ) { + encoder.encodeMap( + obj = obj.records, + keyEncoder = keyEncoder, + valueEncoder = engagementRecordSerializer + ) + } + + private fun encodeVersionHistory(encoder: Encoder, versionHistory: VersionHistory) = + encoder.encodeList(versionHistory.items) { item -> + encodeDouble(item.timestamp) + encodeLong(item.versionCode) + encodeString(item.versionName) + } + + override fun decode(decoder: Decoder): EngagementData { + val events = decodeEventRecords(decoder) + val interactions = decodeInteractionRecords(decoder) + + return try { + EngagementData( + events = events, + interactions = interactions, + interactionResponses = decodeInteractionResponsesRecords(decoder), + versionHistory = decodeVersionHistory(decoder) + ) + } catch (e: Exception) { + Log.e(MIGRATION, "Failed to decode InteractionResponses. Skipping.", e) + EngagementData(events, interactions) + } + } + + private fun decodeEventRecords(decoder: Decoder): EngagementRecords { + return decodeEngagementRecords(decoder, keyDecoder = eventSerializer) + } + + private fun decodeInteractionRecords(decoder: Decoder): EngagementRecords { + return decodeEngagementRecords(decoder, keyDecoder = interactionIdSerializer) + } + + private fun decodeInteractionResponsesRecords(decoder: Decoder): MutableMap { + return decoder.decodeMap( + keyDecoder = interactionIdSerializer, + valueDecoder = interactionResponseDataSerializer + ) + } + + private fun decodeEngagementRecords( + decoder: Decoder, + keyDecoder: TypeDecoder + ) = EngagementRecords( + records = decoder.decodeMap( + keyDecoder = keyDecoder, + valueDecoder = engagementRecordSerializer + ) + ) + + private fun decodeVersionHistory(decoder: Decoder) = VersionHistory( + items = decoder.decodeList { + VersionHistoryItem( + timestamp = decodeDouble(), + versionCode = decodeLong(), + versionName = decodeString() + ) + } + ) + } + } + + val conversationSerializer: TypeSerializer by lazy { + object : TypeSerializer { + override fun encode(encoder: Encoder, value: Conversation) { + encoder.encodeString(value.localIdentifier) + encoder.encodeNullableString(value.conversationToken) + encoder.encodeNullableString(value.conversationId) + deviceSerializer.encode(encoder, value.device) + personSerializer.encode(encoder, value.person) + sdkSerializer.encode(encoder, value.sdk) + appReleaseSerializer.encode(encoder, value.appRelease) + configurationSerializer.encode(encoder, value.configuration) + randomSamplingSerializer.encode(encoder, value.randomSampling) + engagementDataSerializer.encode(encoder, value.engagementData) + } + + override fun decode(decoder: Decoder): Conversation { + return Conversation( + localIdentifier = decoder.decodeString(), + conversationToken = decoder.decodeNullableString(), + conversationId = decoder.decodeNullableString(), + device = deviceSerializer.decode(decoder), + person = personSerializer.decode(decoder), + sdk = sdkSerializer.decode(decoder), + appRelease = appReleaseSerializer.decode(decoder), + configuration = configurationSerializer.decode(decoder), + randomSampling = randomSamplingSerializer.decode(decoder), + engagementManifest = EngagementManifest(), // EngagementManifest is serialized separately + engagementData = engagementDataSerializer.decode(decoder) + ) + } + } + } +} diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/DefaultEngagement.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/DefaultEngagement.kt index 583227d4..f28eca83 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/DefaultEngagement.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/DefaultEngagement.kt @@ -24,6 +24,8 @@ internal typealias RecordInteractionCallback = (interaction: Interaction) -> Uni internal typealias RecordInteractionResponsesCallback = (Map>) -> Unit +internal typealias RecordCurrentAnswerCallback = (Map>, Boolean) -> Unit + @Suppress("FoldInitializerAndIfToElvis") internal data class DefaultEngagement( private val interactionDataProvider: InteractionDataProvider, @@ -31,7 +33,8 @@ internal data class DefaultEngagement( private val interactionEngagement: InteractionEngagement, private val recordEvent: RecordEventCallback, private val recordInteraction: RecordInteractionCallback, - private val recordInteractionResponses: RecordInteractionResponsesCallback + private val recordInteractionResponses: RecordInteractionResponsesCallback, + private val recordCurrentAnswer: RecordCurrentAnswerCallback ) : Engagement { @WorkerThread override fun engage( @@ -87,6 +90,19 @@ internal data class DefaultEngagement( return engage(context, interaction) } + override fun engageToRecordCurrentAnswer( + interactionResponses: Map>, + reset: Boolean + ) { + recordCurrentAnswer(interactionResponses, reset) + } + + override fun getNextQuestionSet( + invocations: List + ): String? { + return interactionDataProvider.getQuestionId(invocations) + } + private fun engage( context: EngagementContext, interaction: Interaction diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/Engagement.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/Engagement.kt index b795198c..c23c92fa 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/Engagement.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/Engagement.kt @@ -5,6 +5,8 @@ import apptentive.com.android.feedback.engagement.criteria.Invocation import apptentive.com.android.feedback.engagement.interactions.InteractionResponse import apptentive.com.android.feedback.model.payloads.ExtendedData import apptentive.com.android.util.InternalUseOnly +import apptentive.com.android.util.Log +import apptentive.com.android.util.LogTags.SURVEY /** * Represents an object responsible for engaging events in a specific context. @@ -22,6 +24,10 @@ interface Engagement { ): EngagementResult fun engage(context: EngagementContext, invocations: List): EngagementResult + + fun engageToRecordCurrentAnswer(interactionResponses: Map>, reset: Boolean) + + fun getNextQuestionSet(invocations: List): String? } /** @@ -46,4 +52,18 @@ internal class NullEngagement : Engagement { ): EngagementResult { return EngagementResult.Error("Unable to engage invocations: SDK is not fully initialized") } + + override fun engageToRecordCurrentAnswer( + interactionResponses: Map>, + reset: Boolean + ) { + Log.e(SURVEY, "Unable to record current answer: SDK is not fully initialized") + } + + override fun getNextQuestionSet( + invocations: List + ): String? { + Log.e(SURVEY, "Unable to get next quest set: SDK is not fully initialized") + return null + } } diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/EngagementContext.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/EngagementContext.kt index 634d49e1..3f0df8f5 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/EngagementContext.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/EngagementContext.kt @@ -50,6 +50,14 @@ open class EngagementContext( invocations = invocations.map(InvocationConverter::convert) ) + fun engageToRecordCurrentAnswer(interactionResponses: Map>, reset: Boolean = false) { + engagement.engageToRecordCurrentAnswer(interactionResponses, reset) + } + + fun getNextQuestionSet(invocations: List) = engagement.getNextQuestionSet( + invocations = invocations.map(InvocationConverter::convert) + ) + fun sendPayload(payload: Payload) = payloadSender.sendPayload(payload) @VisibleForTesting diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/InteractionDataProvider.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/InteractionDataProvider.kt index 08a6a7f5..69bcf921 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/InteractionDataProvider.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/InteractionDataProvider.kt @@ -8,4 +8,6 @@ import apptentive.com.android.util.InternalUseOnly interface InteractionDataProvider { fun getInteractionData(event: Event): InteractionData? fun getInteractionData(invocations: List): InteractionData? + + fun getQuestionId(invocations: List): String? } diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/ConditionalOperator.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/ConditionalOperator.kt index ae490b58..10a13221 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/ConditionalOperator.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/ConditionalOperator.kt @@ -44,9 +44,9 @@ internal interface ConditionalOperator { private val exists: ConditionalOperator by lazy { object : ConditionalOperator { override fun apply(first: Any?, second: Any?): Boolean { - return when (second) { - null -> false - !is Boolean -> false + return when { + second == null || second !is Boolean -> false + (first is Iterable<*> && first.none()) -> !second else -> { val exists = first != null exists == second diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/CriteriaInteractionDataProvider.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/CriteriaInteractionDataProvider.kt index 683d984f..5af8dc8a 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/CriteriaInteractionDataProvider.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/CriteriaInteractionDataProvider.kt @@ -23,6 +23,10 @@ internal class CriteriaInteractionDataProvider( return interactions[interactionId] } + override fun getQuestionId(invocations: List): String? { + return getInteractionId(invocations) + } + private fun getInteractionId(invocations: List): InteractionId? { for (invocation in invocations) { try { diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/DefaultTargetingState.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/DefaultTargetingState.kt index 09ff611b..72f84a46 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/DefaultTargetingState.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/DefaultTargetingState.kt @@ -68,6 +68,8 @@ internal data class DefaultTargetingState( is interactions.last_invoked_at.total -> engagementData.interactions.lastInvoke(field.interactionId) is interactions.answers.id -> engagementData.interactionResponses[field.responseId]?.responses is interactions.answers.value -> engagementData.interactionResponses[field.responseId]?.responses + is interactions.current_answer.id -> engagementData.interactionResponses[field.responseId]?.currentResponses + is interactions.current_answer.value -> engagementData.interactionResponses[field.responseId]?.currentResponses is person.name -> person.name is person.email -> person.email diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/Field.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/Field.kt index f6d91f44..d917658e 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/Field.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/Field.kt @@ -120,6 +120,17 @@ sealed class Field(val type: Type, val description: String) { description = "answer value responseId:$responseId" ) } + + object current_answer { + data class id(val responseId: InteractionId) : Field( + type = Type.Any, // Can be String or Boolean + description = "current answer id responseId:$responseId" + ) + data class value(val responseId: InteractionId) : Field( + type = Type.Any, // Can be String, Boolean, or Long + description = "current answer value responseId:$responseId" + ) + } } object person { @@ -330,6 +341,10 @@ sealed class Field(val type: Type, val description: String) { "id" -> return interactions.answers.id(interaction_instance_id) "value" -> return interactions.answers.value(interaction_instance_id) } + "current_answer" -> when (components[3]) { + "id" -> return interactions.current_answer.id(interaction_instance_id) + "value" -> return interactions.current_answer.value(interaction_instance_id) + } } } "person" -> when (components[1]) { diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/InteractionResponseCriteria.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/InteractionResponseCriteria.kt new file mode 100644 index 00000000..863efca4 --- /dev/null +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/criteria/InteractionResponseCriteria.kt @@ -0,0 +1,10 @@ +package apptentive.com.android.feedback.engagement.criteria + +import apptentive.com.android.util.InternalUseOnly + +@InternalUseOnly +data class InteractionResponseCriteria(val criteria: Map) : InteractionCriteria { + override fun isMet(state: TargetingState, verbose: Boolean): Boolean { + TODO("Not yet implemented") + } +} diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/interactions/InteractionResponseData.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/interactions/InteractionResponseData.kt index 217b61eb..93a0d66a 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/interactions/InteractionResponseData.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/engagement/interactions/InteractionResponseData.kt @@ -1,30 +1,11 @@ package apptentive.com.android.feedback.engagement.interactions -import apptentive.com.android.feedback.engagement.criteria.DateTime import apptentive.com.android.feedback.model.EngagementRecord -import apptentive.com.android.feedback.utils.VersionCode -import apptentive.com.android.feedback.utils.VersionName import apptentive.com.android.util.InternalUseOnly @InternalUseOnly data class InteractionResponseData( val responses: Set = setOf(), + val currentResponses: Set = setOf(), val record: EngagementRecord = EngagementRecord() -) { - fun totalInvokes(): Long = record.getTotalInvokes() - fun invokesForVersionCode(versionCode: VersionCode): Long = - record.invokesForVersionCode(versionCode) ?: 0 - - fun invokesForVersionName(versionName: VersionName): Long = - record.invokesForVersionName(versionName) ?: 0 - - fun lastInvoke(): DateTime = record.getLastInvoked() - - fun addInvoke( - versionName: VersionName, - versionCode: VersionCode, - lastInvoked: DateTime - ): EngagementRecord { - return record.addInvoke(versionCode, versionName, lastInvoked) - } -} +) diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/model/EngagementData.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/model/EngagementData.kt index d40194b6..4d38985c 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/model/EngagementData.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/model/EngagementData.kt @@ -62,6 +62,32 @@ data class EngagementData( interactionId, InteractionResponseData( responses = responses.union(recordedInteraction?.responses.orEmpty()), + currentResponses = setOf(), + record = (recordedInteraction?.record ?: EngagementRecord()).addInvoke( + versionName = versionName, + versionCode = versionCode, + lastInvoked = lastInvoked + ) + ) + ) + } + ) + + fun updateCurrentAnswer( + interactionId: InteractionId, + responses: Set, + versionName: VersionName, + versionCode: VersionCode, + lastInvoked: DateTime, + reset: Boolean + ) = copy( + interactionResponses = interactionResponses.apply { + val recordedInteraction = get(interactionId) + put( + interactionId, + InteractionResponseData( + responses = recordedInteraction?.responses.orEmpty(), + currentResponses = if (reset) setOf() else responses, record = (recordedInteraction?.record ?: EngagementRecord()).addInvoke( versionName = versionName, versionCode = versionCode, diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/model/InvocationData.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/model/InvocationData.kt index 6ec6456c..b1261f33 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/model/InvocationData.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/model/InvocationData.kt @@ -2,5 +2,15 @@ package apptentive.com.android.feedback.model import apptentive.com.android.util.InternalUseOnly +/** + * Data container with the criteria to be evaluated. + * + * @param interactionId the id if the interaction or question to be shown + * @param criteria map of key and values. The keys are expected to predefined Fields in the DefaultTargetingState + */ + @InternalUseOnly -data class InvocationData(val interactionId: String, val criteria: Map = emptyMap()) +data class InvocationData( + val interactionId: String, + val criteria: Map = emptyMap(), +) diff --git a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/utils/ThrottleUtils.kt b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/utils/ThrottleUtils.kt index 32daf7d9..9fc22fd8 100644 --- a/apptentive-feedback/src/main/java/apptentive/com/android/feedback/utils/ThrottleUtils.kt +++ b/apptentive-feedback/src/main/java/apptentive/com/android/feedback/utils/ThrottleUtils.kt @@ -49,12 +49,19 @@ internal object ThrottleUtils { fun shouldThrottleResetConversation(): Boolean { val sharedPrefDataStore = DependencyProvider.of() - val sdkVersion: String = sharedPrefDataStore.getString(SharedPrefConstants.THROTTLE_UTILS, SharedPrefConstants.CONVERSATION_RESET_THROTTLE) + val sdkVersion: String = sharedPrefDataStore.getString( + SharedPrefConstants.THROTTLE_UTILS, + SharedPrefConstants.CONVERSATION_RESET_THROTTLE + ) val apptentiveSDKVersion: String = Constants.SDK_VERSION return if (sdkVersion.isEmpty() || sdkVersion != apptentiveSDKVersion) { Log.d(CONVERSATION, "Conversation reset NOT throttled") - sharedPrefDataStore.putString(SharedPrefConstants.THROTTLE_UTILS, SharedPrefConstants.CONVERSATION_RESET_THROTTLE, Constants.SDK_VERSION) + sharedPrefDataStore.putString( + SharedPrefConstants.THROTTLE_UTILS, + SharedPrefConstants.CONVERSATION_RESET_THROTTLE, + Constants.SDK_VERSION + ) false } else { Log.d(CONVERSATION, "Conversation reset throttled") diff --git a/apptentive-feedback/src/test/java/apptentive/com/android/feedback/conversation/ConversationManagerTest.kt b/apptentive-feedback/src/test/java/apptentive/com/android/feedback/conversation/ConversationManagerTest.kt index c1dc825b..65084c0f 100644 --- a/apptentive-feedback/src/test/java/apptentive/com/android/feedback/conversation/ConversationManagerTest.kt +++ b/apptentive-feedback/src/test/java/apptentive/com/android/feedback/conversation/ConversationManagerTest.kt @@ -36,10 +36,17 @@ import com.google.common.truth.Truth.assertThat import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Ignore import org.junit.Test class ConversationManagerTest : TestCase() { + + @Before + override fun setUp() { + DependencyProvider.register(MockAndroidSharedPrefDataStore()) + } + @Test fun getActiveConversation() { val fetchResponse = ConversationCredentials( @@ -205,7 +212,8 @@ internal fun createConversationManager( ) } -private class MockConversationRepository(val throwException: Boolean = false) : ConversationRepository { +private class MockConversationRepository(val throwException: Boolean = false) : + ConversationRepository { private var conversation: Conversation? = null override fun createConversation(): Conversation { diff --git a/apptentive-feedback/src/test/java/apptentive/com/android/feedback/conversation/DefaultConversationSerializerTest.kt b/apptentive-feedback/src/test/java/apptentive/com/android/feedback/conversation/DefaultConversationSerializerTest.kt index e33e5e67..5911a5b9 100644 --- a/apptentive-feedback/src/test/java/apptentive/com/android/feedback/conversation/DefaultConversationSerializerTest.kt +++ b/apptentive-feedback/src/test/java/apptentive/com/android/feedback/conversation/DefaultConversationSerializerTest.kt @@ -1,13 +1,17 @@ package apptentive.com.android.feedback.conversation import apptentive.com.android.TestCase +import apptentive.com.android.core.DependencyProvider import apptentive.com.android.encryption.EncryptionFactory import apptentive.com.android.encryption.NotEncrypted import apptentive.com.android.feedback.createMockConversation +import apptentive.com.android.feedback.engagement.util.MockAndroidSharedPrefDataStore import apptentive.com.android.feedback.model.EngagementManifest +import apptentive.com.android.platform.AndroidSharedPrefDataStore import com.google.common.truth.Truth.assertThat import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -19,6 +23,13 @@ class DefaultConversationSerializerTest : TestCase() { @get:Rule val tempFolder = TemporaryFolder() + @Before + override fun setUp() { + val mockDataStore = MockAndroidSharedPrefDataStore() + mockDataStore.putString("test", "test", "test") + DependencyProvider.register(mockDataStore) + } + @Test fun testLoadingNonExistingConversation() { val conversationFile = createTempFile("conversation.bin") diff --git a/apptentive-feedback/src/test/java/apptentive/com/android/feedback/conversation/SerializersTest.kt b/apptentive-feedback/src/test/java/apptentive/com/android/feedback/conversation/SerializersTest.kt index edd45796..b82193d8 100644 --- a/apptentive-feedback/src/test/java/apptentive/com/android/feedback/conversation/SerializersTest.kt +++ b/apptentive-feedback/src/test/java/apptentive/com/android/feedback/conversation/SerializersTest.kt @@ -30,31 +30,31 @@ class SerializersTest { @Test fun versionCodeSerializer() { val value: VersionCode = 1234567890 - checkSerializer(Serializers.versionCodeSerializer, value) + checkSerializer(DefaultSerializers.versionCodeSerializer, value) } @Test fun versionNameSerializer() { val versionName: VersionName = "1.2.3" - checkSerializer(Serializers.versionNameSerializer, versionName) + checkSerializer(DefaultSerializers.versionNameSerializer, versionName) } @Test fun interactionIdSerializer() { val interactionId: InteractionId = "1234567890" - checkSerializer(Serializers.interactionIdSerializer, interactionId) + checkSerializer(DefaultSerializers.interactionIdSerializer, interactionId) } @Test fun dateTimeSerializer() { - checkSerializer(Serializers.dateTimeSerializer, DateTime(1234567890.0)) + checkSerializer(DefaultSerializers.dateTimeSerializer, DateTime(1234567890.0)) } @Test fun customDataSerializer() { - checkSerializer(Serializers.customDataSerializer, CustomData()) + checkSerializer(DefaultSerializers.customDataSerializer, CustomData()) checkSerializer( - Serializers.customDataSerializer, + DefaultSerializers.customDataSerializer, CustomData( content = mapOf( "key1" to true, @@ -73,47 +73,47 @@ class SerializersTest { @Test fun deviceSerializer() { - checkSerializer(Serializers.deviceSerializer, mockDevice) + checkSerializer(DefaultSerializers.deviceSerializer, mockDevice) } @Test fun personSerializer() { - checkSerializer(Serializers.personSerializer, Person()) - checkSerializer(Serializers.personSerializer, mockPerson) + checkSerializer(DefaultSerializers.personSerializer, Person()) + checkSerializer(DefaultSerializers.personSerializer, mockPerson) } @Test fun sdkSerializer() { - checkSerializer(Serializers.sdkSerializer, mockSdk) + checkSerializer(DefaultSerializers.sdkSerializer, mockSdk) } @Test fun appReleaseSerializer() { - checkSerializer(Serializers.appReleaseSerializer, mockAppRelease) + checkSerializer(DefaultSerializers.appReleaseSerializer, mockAppRelease) } @Test fun getEngagementRecordSerializer() { - checkSerializer(Serializers.engagementRecordSerializer, EngagementRecord()) + checkSerializer(DefaultSerializers.engagementRecordSerializer, EngagementRecord()) val record = EngagementRecord( totalInvokes = 3, versionNameLookup = mutableMapOf("1.0.0" to 2L, "1.0.1" to 1L), versionCodeLookup = mutableMapOf(100L to 2L, 101L to 1L), lastInvoked = DateTime(1234567890.0) ) - checkSerializer(Serializers.engagementRecordSerializer, record) + checkSerializer(DefaultSerializers.engagementRecordSerializer, record) } @Test fun eventSerializer() { - checkSerializer(Serializers.eventSerializer, Event.local("event1")) - checkSerializer(Serializers.eventSerializer, Event.internal("event2")) + checkSerializer(DefaultSerializers.eventSerializer, Event.local("event1")) + checkSerializer(DefaultSerializers.eventSerializer, Event.internal("event2")) } @Test fun engagementDataSerializer() { - checkSerializer(Serializers.engagementDataSerializer, EngagementData()) - checkSerializer(Serializers.engagementDataSerializer, mockEngagementData) + checkSerializer(DefaultSerializers.engagementDataSerializer, EngagementData()) + checkSerializer(DefaultSerializers.engagementDataSerializer, mockEngagementData) } @Test @@ -128,7 +128,7 @@ class SerializersTest { appRelease = mockAppRelease, engagementData = mockEngagementData ) - checkSerializer(Serializers.conversationSerializer, conversation) + checkSerializer(DefaultSerializers.conversationSerializer, conversation) } @Test diff --git a/apptentive-feedback/src/test/java/apptentive/com/android/feedback/engagement/DefaultEngagementTest.kt b/apptentive-feedback/src/test/java/apptentive/com/android/feedback/engagement/DefaultEngagementTest.kt index 0c5a474d..98956356 100644 --- a/apptentive-feedback/src/test/java/apptentive/com/android/feedback/engagement/DefaultEngagementTest.kt +++ b/apptentive-feedback/src/test/java/apptentive/com/android/feedback/engagement/DefaultEngagementTest.kt @@ -25,7 +25,8 @@ class DefaultEngagementTest : TestCase() { interactionEngagement = interactionEngagement, recordEvent = ::recordEvent, recordInteraction = ::recordInteraction, - recordInteractionResponses = ::recordInteractionResponses + recordInteractionResponses = ::recordInteractionResponses, + recordCurrentAnswer = ::recordCurrentAnswer ) } @@ -69,4 +70,7 @@ class DefaultEngagementTest : TestCase() { private fun recordInteractionResponses(interactionResponses: Map>) { addResult("Interaction Responses: $interactionResponses") } + + private fun recordCurrentAnswer(interactionResponses: Map>, reset: Boolean) { + } } diff --git a/apptentive-feedback/src/test/java/apptentive/com/android/feedback/model/ConversationTest.kt b/apptentive-feedback/src/test/java/apptentive/com/android/feedback/model/ConversationTest.kt index fc925b90..c80550f3 100644 --- a/apptentive-feedback/src/test/java/apptentive/com/android/feedback/model/ConversationTest.kt +++ b/apptentive-feedback/src/test/java/apptentive/com/android/feedback/model/ConversationTest.kt @@ -1,14 +1,23 @@ package apptentive.com.android.feedback.model import apptentive.com.android.TestCase +import apptentive.com.android.core.DependencyProvider import apptentive.com.android.encryption.EncryptionFactory import apptentive.com.android.encryption.NotEncrypted import apptentive.com.android.feedback.conversation.DefaultConversationSerializer +import apptentive.com.android.feedback.engagement.util.MockAndroidSharedPrefDataStore +import apptentive.com.android.platform.AndroidSharedPrefDataStore import com.google.common.truth.Truth.assertThat +import org.junit.Before import org.junit.Test import java.io.File class ConversationTest : TestCase() { + + @Before + override fun setUp() { + DependencyProvider.register(MockAndroidSharedPrefDataStore()) + } @Test fun binaryFileSerialization() { val expected = Conversation( diff --git a/apptentive-message-center/src/main/java/apptentive/com/android/feedback/messagecenter/view/ProfileActivity.kt b/apptentive-message-center/src/main/java/apptentive/com/android/feedback/messagecenter/view/ProfileActivity.kt index cf69cadd..525808ad 100644 --- a/apptentive-message-center/src/main/java/apptentive/com/android/feedback/messagecenter/view/ProfileActivity.kt +++ b/apptentive-message-center/src/main/java/apptentive/com/android/feedback/messagecenter/view/ProfileActivity.kt @@ -5,6 +5,7 @@ import apptentive.com.android.feedback.messagecenter.R import apptentive.com.android.feedback.messagecenter.utils.MessageCenterEvents import apptentive.com.android.feedback.messagecenter.view.custom.ProfileView import apptentive.com.android.ui.ApptentiveGenericDialog +import apptentive.com.android.ui.hideSoftKeyboard import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.button.MaterialButton import com.google.android.material.textview.MaterialTextView @@ -35,6 +36,7 @@ internal class ProfileActivity : BaseProfileActivity() { setSupportActionBar(topAppBar) topAppBar.setNavigationOnClickListener { + it.hideSoftKeyboard() viewModel.exitProfileView(profileView.getName(), profileView.getEmail().trim()) } diff --git a/apptentive-message-center/src/test/java/apptentive/com/android/feedback/messagecenter/viewmodel/MessageCenterViewModelTest.kt b/apptentive-message-center/src/test/java/apptentive/com/android/feedback/messagecenter/viewmodel/MessageCenterViewModelTest.kt index 8690151b..54d94da0 100644 --- a/apptentive-message-center/src/test/java/apptentive/com/android/feedback/messagecenter/viewmodel/MessageCenterViewModelTest.kt +++ b/apptentive-message-center/src/test/java/apptentive/com/android/feedback/messagecenter/viewmodel/MessageCenterViewModelTest.kt @@ -5,7 +5,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import apptentive.com.android.TestCase import apptentive.com.android.concurrent.Executor import apptentive.com.android.core.DependencyProvider -import apptentive.com.android.core.Provider import apptentive.com.android.core.isInThePast import apptentive.com.android.core.toSeconds import apptentive.com.android.feedback.EngagementResult @@ -13,10 +12,9 @@ import apptentive.com.android.feedback.backend.MessageCenterService import apptentive.com.android.feedback.dependencyprovider.MessageCenterModelProvider import apptentive.com.android.feedback.dependencyprovider.createMessageCenterViewModel import apptentive.com.android.feedback.engagement.EngageArgs -import apptentive.com.android.feedback.engagement.EngagementContext -import apptentive.com.android.feedback.engagement.EngagementContextFactory import apptentive.com.android.feedback.engagement.Event import apptentive.com.android.feedback.engagement.MockEngagementContext +import apptentive.com.android.feedback.engagement.MockEngagementContextFactory import apptentive.com.android.feedback.engagement.interactions.InteractionType import apptentive.com.android.feedback.message.MessageCenterInteraction import apptentive.com.android.feedback.message.MessageManager @@ -500,17 +498,6 @@ class MockExecutor : Executor { } } -class MockEngagementContextFactory(val getEngagementContext: () -> EngagementContext) : - Provider { - override fun get(): EngagementContextFactory { - return object : EngagementContextFactory { - override fun engagementContext(): EngagementContext { - return getEngagementContext() - } - } - } -} - class MockMessageRepository : MessageRepository { override fun getLastReceivedMessageIDFromEntries(): String = "" diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/BaseSurveyActivity.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/BaseSurveyActivity.kt index cb15cfb6..b2eb0320 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/BaseSurveyActivity.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/BaseSurveyActivity.kt @@ -18,7 +18,7 @@ import apptentive.com.android.ui.ViewModelFactory * * ApptentiveActivityInfo added for easier integration of future features */ -open class BaseSurveyActivity : ApptentiveViewModelActivity(), ApptentiveActivityInfo { +internal open class BaseSurveyActivity : ApptentiveViewModelActivity(), ApptentiveActivityInfo { /** * @property viewModel [SurveyViewModel] class that is responsible for preparing @@ -26,7 +26,13 @@ open class BaseSurveyActivity : ApptentiveViewModelActivity(), ApptentiveActivit * */ val viewModel: SurveyViewModel by viewModels { - ViewModelFactory { createSurveyViewModel() } + ViewModelFactory { + try { + createSurveyViewModel() + } catch (exception: Exception) { + throw IllegalStateException("Issue creating SurveyViewModel $exception") + } + } } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/SurveyActivity.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/SurveyActivity.kt index e9155011..fc7c8b81 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/SurveyActivity.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/SurveyActivity.kt @@ -5,29 +5,44 @@ import android.os.Build import android.os.Bundle import android.text.method.LinkMovementMethod import android.view.MotionEvent +import android.view.View import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo import android.widget.EditText +import android.widget.LinearLayout import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE +import androidx.viewpager2.widget.ViewPager2 import apptentive.com.android.feedback.survey.view.SurveyQuestionViewHolderFactory +import apptentive.com.android.feedback.survey.view.SurveySegmentedProgressBar import apptentive.com.android.feedback.survey.viewmodel.MultiChoiceQuestionListItem import apptentive.com.android.feedback.survey.viewmodel.RangeQuestionListItem import apptentive.com.android.feedback.survey.viewmodel.SingleLineQuestionListItem import apptentive.com.android.feedback.survey.viewmodel.SurveyFooterListItem import apptentive.com.android.feedback.survey.viewmodel.SurveyHeaderListItem +import apptentive.com.android.feedback.survey.viewmodel.SurveyIntroductionPageItem import apptentive.com.android.feedback.survey.viewmodel.SurveyListItem.Type.Footer import apptentive.com.android.feedback.survey.viewmodel.SurveyListItem.Type.Header +import apptentive.com.android.feedback.survey.viewmodel.SurveyListItem.Type.Introduction import apptentive.com.android.feedback.survey.viewmodel.SurveyListItem.Type.MultiChoiceQuestion import apptentive.com.android.feedback.survey.viewmodel.SurveyListItem.Type.RangeQuestion import apptentive.com.android.feedback.survey.viewmodel.SurveyListItem.Type.SingleLineQuestion +import apptentive.com.android.feedback.survey.viewmodel.SurveyListItem.Type.Success +import apptentive.com.android.feedback.survey.viewmodel.SurveySuccessPageItem import apptentive.com.android.feedback.survey.viewmodel.register import apptentive.com.android.ui.ApptentiveGenericDialog +import apptentive.com.android.ui.ApptentivePagerAdapter import apptentive.com.android.ui.LayoutViewHolderFactory import apptentive.com.android.ui.ListViewAdapter import apptentive.com.android.ui.hideSoftKeyboard +import apptentive.com.android.util.Log +import apptentive.com.android.util.LogTags.SURVEY import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.button.MaterialButton +import com.google.android.material.progressindicator.LinearProgressIndicator import com.google.android.material.textview.MaterialTextView internal class SurveyActivity : BaseSurveyActivity() { @@ -37,38 +52,78 @@ internal class SurveyActivity : BaseSurveyActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.apptentive_activity_survey) - title = viewModel.title // So TalkBack announces the survey title + try { + title = viewModel.title // So TalkBack announces the survey title - supportActionBar?.hide() + supportActionBar?.hide() - val topAppBar = findViewById(R.id.apptentive_top_app_bar) - topAppBar.setNavigationOnClickListener { - viewModel.exit(showConfirmation = true) - } + val topAppBar = findViewById(R.id.apptentive_top_app_bar) + topAppBar.setNavigationOnClickListener { + it.hideSoftKeyboard() + viewModel.exit(showConfirmation = true) + } + + val topAppBarTitle = findViewById(R.id.apptentive_survey_title) + topAppBarTitle.text = viewModel.title + + if (viewModel.isPaged) setupPagedSurvey() else setupListSurvey() + + val bottomAppBar = findViewById(R.id.apptentive_bottom_app_bar) + bottomAppBar.importantForAccessibility = + if (viewModel.termsAndConditions.isNullOrEmpty()) View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + else View.IMPORTANT_FOR_ACCESSIBILITY_YES - val topAppBarTitle = findViewById(R.id.apptentive_survey_title) - topAppBarTitle.text = viewModel.title + val termsAndConditionsText = findViewById(R.id.apptentive_terms_and_conditions) + termsAndConditionsText.movementMethod = LinkMovementMethod.getInstance() + termsAndConditionsText.text = viewModel.termsAndConditions + + viewModel.exitStream.observe(this) { + finish() + } + + viewModel.showConfirmation.observe(this) { + if (it) { + with(viewModel.surveyCancelConfirmationDisplay) { + confirmationDialog = ApptentiveGenericDialog().getGenericDialog( + context = this@SurveyActivity, + title = title ?: getString(R.string.confirmation_dialog_title), + message = message ?: getString(R.string.confirmation_dialog_message), + positiveButton = ApptentiveGenericDialog.DialogButton(positiveButtonMessage ?: getString(R.string.apptentive_cancel)) { + viewModel.onBackToSurveyFromConfirmationDialog() + }, + negativeButton = ApptentiveGenericDialog.DialogButton(negativeButtonMessage ?: getString(R.string.apptentive_close)) { + viewModel.exit(showConfirmation = false) + } + ) - val adapter = createAdapter() - val recyclerView = findViewById(R.id.apptentive_survey_recycler_view) - recyclerView.adapter = adapter + confirmationDialog?.show() + } + } + } + } catch (exception: Exception) { + Log.e(SURVEY, "Error launching survey activity $exception") + finish() + } + } - val termsAndConditionsText = findViewById(R.id.apptentive_terms_and_conditions) - termsAndConditionsText.movementMethod = LinkMovementMethod.getInstance() - termsAndConditionsText.text = viewModel.termsAndConditions + private fun setupListSurvey() { + val listAdapter = createListAdapter() + val listRecyclerView = findViewById(R.id.apptentive_list_survey_recycler_view) + listRecyclerView.adapter = listAdapter + listRecyclerView.isVisible = true viewModel.listItems.observe(this) { items -> - adapter.submitList(items) + listAdapter.submitList(items) } viewModel.firstInvalidQuestionIndex.observe(this) { firstErrorPosition -> if (firstErrorPosition != -1) { // Check if item is fully visible on screen before trying to scroll - val layoutManger = (recyclerView.layoutManager as LinearLayoutManager) + val layoutManger = (listRecyclerView.layoutManager as LinearLayoutManager) if (firstErrorPosition !in layoutManger.findFirstCompletelyVisibleItemPosition()..layoutManger.findLastCompletelyVisibleItemPosition()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + listRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (newState == SCROLL_STATE_IDLE) { val errorView = layoutManger.findViewByPosition(firstErrorPosition) @@ -78,36 +133,83 @@ internal class SurveyActivity : BaseSurveyActivity() { } }) } - recyclerView.smoothScrollToPosition(firstErrorPosition) + listRecyclerView.smoothScrollToPosition(firstErrorPosition) } else { val errorView = layoutManger.findViewByPosition(firstErrorPosition) errorView?.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED) } } } + } - viewModel.exitStream.observe(this) { - finish() - } + private fun setupPagedSurvey() { + val surveyPager = findViewById(R.id.apptentive_survey_view_pager) + val pagedAdapter = createPagedAdapter() - viewModel.showConfirmation.observe(this) { - if (it) { - with(viewModel.surveyCancelConfirmationDisplay) { - confirmationDialog = ApptentiveGenericDialog().getGenericDialog( - context = this@SurveyActivity, - title = title ?: getString(R.string.confirmation_dialog_title), - message = message ?: getString(R.string.confirmation_dialog_message), - positiveButton = ApptentiveGenericDialog.DialogButton(positiveButtonMessage ?: getString(R.string.apptentive_cancel)) { - viewModel.onBackToSurveyFromConfirmationDialog() - }, - negativeButton = ApptentiveGenericDialog.DialogButton(negativeButtonMessage ?: getString(R.string.apptentive_close)) { - viewModel.exit(showConfirmation = false) - } - ) - - confirmationDialog?.show() + surveyPager.setPageTransformer { _, _ -> } // Disable update animation when error shows + surveyPager.isUserInputEnabled = false // Disable swipe to change page + surveyPager.adapter = pagedAdapter + + // For Talkback: Sets accessibility focus to the question after page change + surveyPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + + if (state == ViewPager2.SCROLL_STATE_IDLE) { + val pagerRecycler = surveyPager.getChildAt(surveyPager.childCount - 1) as? RecyclerView + pagerRecycler?.performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null) + pagerRecycler?.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED) + pagerRecycler?.requestFocus() } } + }) + + viewModel.currentPage.observe(this) { page -> + pagedAdapter.addOrUpdatePage(page, currentFocus is EditText) + surveyPager.setCurrentItem(pagedAdapter.itemCount - 1, true) + } + + setupNextButton() + setupProgressBar() + + val pagedLayout = findViewById(R.id.apptentive_paged_survey_layout) + pagedLayout.isVisible = true + } + + private fun setupNextButton() { + val nextButton = findViewById(R.id.apptentive_next_button) + + viewModel.advanceButtonText.observe(this) { advanceText -> + nextButton.text = advanceText + } + + nextButton.setOnClickListener { + it.hideSoftKeyboard() + viewModel.advancePage() + } + } + + private fun setupProgressBar() { + val useSegmentedProgressBar = viewModel.pageCount in 2..10 + + if (useSegmentedProgressBar) { + val segmentedProgressBar = findViewById(R.id.apptentive_progress_bar_segmented) + segmentedProgressBar.isVisible = true + segmentedProgressBar.setSegmentCount(viewModel.pageCount) + viewModel.progressBarNumber.observe(this) { progressNumber -> + if (progressNumber != null) segmentedProgressBar.updateProgress(progressNumber) + else segmentedProgressBar.visibility = View.INVISIBLE + } + } else { + val linearProgressBar = findViewById(R.id.apptentive_progress_bar_linear) + linearProgressBar.isVisible = true + viewModel.progressBarNumber.observe(this) { progressNumber -> + if (progressNumber != null) linearProgressBar.setProgressCompat( + (progressNumber + 1) * 100 / viewModel.pageCount, + true + ) + else linearProgressBar.visibility = View.INVISIBLE + } } } @@ -126,7 +228,7 @@ internal class SurveyActivity : BaseSurveyActivity() { // We need to remove focus from any EditText when user touches outside // of it. Otherwise, the focus would weirdly jump while scrolling through items. // see: https://stackoverflow.com/a/28939113 - if (event.action == MotionEvent.ACTION_DOWN) { + if (!viewModel.isPaged && event.action == MotionEvent.ACTION_DOWN) { val focusedView = currentFocus if (focusedView is EditText) { val outRect = Rect() @@ -141,8 +243,7 @@ internal class SurveyActivity : BaseSurveyActivity() { return super.dispatchTouchEvent(event) } - private fun createAdapter() = ListViewAdapter().apply { - // Header view + private fun createListAdapter() = ListViewAdapter().apply { register( type = Header, factory = LayoutViewHolderFactory(R.layout.apptentive_survey_header) { @@ -150,42 +251,83 @@ internal class SurveyActivity : BaseSurveyActivity() { } ) - // Single Line Question register( type = SingleLineQuestion, - factory = SurveyQuestionViewHolderFactory(R.layout.apptentive_survey_question_singleline) { + factory = SurveyQuestionViewHolderFactory(R.layout.apptentive_survey_question_singleline, false) { SingleLineQuestionListItem.ViewHolder(it) { questionId, text -> viewModel.updateAnswer(questionId, text) } } ) - // Range Question + register( type = RangeQuestion, - factory = SurveyQuestionViewHolderFactory(R.layout.apptentive_survey_question_range) { + factory = SurveyQuestionViewHolderFactory(R.layout.apptentive_survey_question_range, false) { RangeQuestionListItem.ViewHolder(it) { questionId, selectedIndex -> viewModel.updateAnswer(questionId, selectedIndex) } } ) - // Multi-choice Question + register( type = MultiChoiceQuestion, - factory = SurveyQuestionViewHolderFactory(R.layout.apptentive_survey_question_multichoice) { + factory = SurveyQuestionViewHolderFactory(R.layout.apptentive_survey_question_multichoice, false) { MultiChoiceQuestionListItem.ViewHolder(it) { questionId, choiceId, selected, text -> viewModel.updateAnswer(questionId, choiceId, selected, text) } } ) - // Submit button register( type = Footer, factory = LayoutViewHolderFactory(R.layout.apptentive_survey_footer) { SurveyFooterListItem.ViewHolder(it) { - viewModel.submit() + viewModel.submitListSurvey() + } + } + ) + } + + private fun createPagedAdapter() = ApptentivePagerAdapter().apply { + register( + type = Introduction, + factory = LayoutViewHolderFactory(R.layout.apptentive_survey_introduction) { + SurveyIntroductionPageItem.ViewHolder(it) + } + ) + + register( + type = SingleLineQuestion, + factory = SurveyQuestionViewHolderFactory(R.layout.apptentive_survey_question_singleline, true) { + SingleLineQuestionListItem.ViewHolder(it, true) { questionId, text -> + viewModel.updateAnswer(questionId, text) + } + } + ) + + register( + type = RangeQuestion, + factory = SurveyQuestionViewHolderFactory(R.layout.apptentive_survey_question_range, true) { + RangeQuestionListItem.ViewHolder(it) { questionId, selectedIndex -> + viewModel.updateAnswer(questionId, selectedIndex) } } ) + + register( + type = MultiChoiceQuestion, + factory = SurveyQuestionViewHolderFactory(R.layout.apptentive_survey_question_multichoice, true) { + MultiChoiceQuestionListItem.ViewHolder(it) { questionId, choiceId, selected, text -> + viewModel.updateAnswer(questionId, choiceId, selected, text) + } + } + ) + + register( + type = Success, + factory = LayoutViewHolderFactory(R.layout.apptentive_survey_success_page) { + SurveySuccessPageItem.ViewHolder(it) + } + ) } } diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/SurveyModelFactory.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/SurveyModelFactory.kt index 3c120297..339cec77 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/SurveyModelFactory.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/SurveyModelFactory.kt @@ -2,9 +2,11 @@ package apptentive.com.android.feedback.survey import apptentive.com.android.core.Provider import apptentive.com.android.feedback.engagement.EngagementContext -import apptentive.com.android.feedback.survey.interaction.DefaultSurveyQuestionConverter +import apptentive.com.android.feedback.survey.interaction.DefaultSurveyQuestionSetConverter import apptentive.com.android.feedback.survey.interaction.SurveyInteraction +import apptentive.com.android.feedback.survey.model.RenderAs import apptentive.com.android.feedback.survey.model.SurveyModel +import apptentive.com.android.util.MissingKeyException internal interface SurveyModelFactory { fun getSurveyModel(): SurveyModel @@ -19,24 +21,27 @@ internal class SurveyModelFactoryProvider( } } +private const val PAGED_SURVEY = "paged" + internal class DefaultSurveyModelFactory( private val engagementContext: EngagementContext, private val interaction: SurveyInteraction ) : SurveyModelFactory { + @Throws(MissingKeyException::class) override fun getSurveyModel(): SurveyModel { return SurveyModel( interactionId = interaction.id, - questions = interaction.questions.map { config -> - DefaultSurveyQuestionConverter().convert( - config = config, - requiredTextMessage = interaction.requiredText - ?: engagementContext.getAppActivity().getString(R.string.apptentive_required) - ) + questionSet = interaction.questionSet.map { config -> + DefaultSurveyQuestionSetConverter().apply { + isPaged = interaction.renderAs == PAGED_SURVEY + }.convert(configuration = config) }, name = interaction.name, - description = interaction.description, - submitText = interaction.submitText, - requiredText = interaction.requiredText, + surveyIntroduction = interaction.description, + submitText = interaction.questionSet.map { config -> + DefaultSurveyQuestionSetConverter().convert(configuration = config) + }.last().buttonText, + requiredText = interaction.requiredText ?: engagementContext.getAppActivity().getString(R.string.apptentive_required), validationError = interaction.validationError, showSuccessMessage = interaction.showSuccessMessage, successMessage = interaction.successMessage, @@ -45,7 +50,10 @@ internal class DefaultSurveyModelFactory( closeConfirmCloseText = interaction.closeConfirmCloseText, closeConfirmBackText = interaction.closeConfirmBackText, termsAndConditionsLinkText = interaction.termsAndConditions?.convertToLink(), - disclaimerText = interaction.disclaimerText + disclaimerText = interaction.disclaimerText, + renderAs = if (interaction.renderAs == PAGED_SURVEY) RenderAs.PAGED else RenderAs.LIST, + introButtonText = interaction.introButtonText, + successButtonText = interaction.successButtonText ) } } diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/interaction/SurveyInteraction.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/interaction/SurveyInteraction.kt index 67b9f57e..71b1a46e 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/interaction/SurveyInteraction.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/interaction/SurveyInteraction.kt @@ -7,22 +7,26 @@ import apptentive.com.android.feedback.engagement.interactions.InteractionType import apptentive.com.android.feedback.survey.interaction.SurveyInteraction.TermsAndConditions internal typealias SurveyQuestionConfiguration = Map +internal typealias SurveyQuestionSetConfiguration = Map /** * @param id interaction id * @param name the name that should be displayed at the top of the survey (corresponds to the Title field on the dashboard). - * @param submitText the text displayed on the submit button (no dashboard setting). * @param description a short description/introductory message (corresponds to the Introduction field on the dashboard). + * @param renderAs whether to display the survey as a list or in pages + * @param introButtonText button text on the introduction page of a paged survey + * @param nextText button text on the question page of the paged survey + * @param questionSet list of questions with invocation logic + * @param isRequired whether to allow the user to cancel the survey * @param requiredText the text displayed adjacent to questions that require a response in order to submit the survey (no dashboard setting). * @param validationError the text to display when attempting to submit a survey that fails validation (no dashboard setting). * @param showSuccessMessage whether to display the next item (corresponds to the Display this message to customers after they complete your survey checkbox on the dashboard). * @param successMessage a short message to display after a survey is successfully submitted (corresponds to the Thank You Message field on the dashboard). + * @param successButtonText button text on the thank you page of the paged survey * @param closeConfirmTitle title to display in the survey cancellation confirmation dialog (cancellation confirmation dialog: when the survey is cancelled/closed after partially filled) * @param closeConfirmMessage a short message in the survey cancellation confirmation dialog, usually to tell the user that their progress will be lost by cancelling the survey * @param closeConfirmCloseText the text displayed on the negative button of the survey cancellation confirmation dialog * @param closeConfirmBackText the text displayed on the positive button of the survey cancellation confirmation dialog - * @param isRequired whether to allow the user to cancel the survey - * @param questions list of questions * @param termsAndConditions [TermsAndConditions] data class that contains a label and link (set on dashboard) * @param disclaimerText a legal disclaimer that will display below the submit button */ @@ -30,17 +34,20 @@ internal class SurveyInteraction( id: String, val name: String?, val description: String?, - val submitText: String?, + val renderAs: String, + val introButtonText: String?, + val nextText: String?, // TODO verify if this is still in use, mock json doesn't have it + val questionSet: List, + val isRequired: Boolean, val requiredText: String?, val validationError: String?, val showSuccessMessage: Boolean, val successMessage: String?, + val successButtonText: String?, val closeConfirmTitle: String?, val closeConfirmMessage: String?, val closeConfirmCloseText: String?, val closeConfirmBackText: String?, - val isRequired: Boolean, - val questions: List, val termsAndConditions: TermsAndConditions?, val disclaimerText: String? ) : Interaction(id, type = InteractionType.Survey) { @@ -61,19 +68,18 @@ internal class SurveyInteraction( "(id=$id, " + "name=\"$name\", " + "description=\"$description\", " + - "submitText=\"$submitText\", " + "requiredText=\"$requiredText\", " + "validationError=\"$validationError\", " + "showSuccessMessage=$showSuccessMessage, " + "successMessage=\"$successMessage\", " + "closeConfirmTitle=\"$closeConfirmTitle\", " + - "closeConfirmMessage=\"$closeConfirmMessage, " + + "closeConfirmMessage=\"$closeConfirmMessage\", " + "closeConfirmCloseText=\"$closeConfirmCloseText\", " + "closeConfirmBackText=\"$closeConfirmBackText\", " + "isRequired=$isRequired, " + - "questions=$questions), " + + "questions=$questionSet, " + "termsAndConditions=$termsAndConditions, " + - "disclaimerText=$disclaimerText" + "disclaimerText=$disclaimerText)" } override fun equals(other: Any?): Boolean { @@ -82,7 +88,6 @@ internal class SurveyInteraction( other !is SurveyInteraction || name != other.name || description != other.description || - submitText != other.submitText || requiredText != other.requiredText || validationError != other.validationError || showSuccessMessage != other.showSuccessMessage || @@ -91,8 +96,12 @@ internal class SurveyInteraction( closeConfirmMessage != other.closeConfirmMessage || closeConfirmBackText != other.closeConfirmBackText || isRequired != other.isRequired || - questions != other.questions || + questionSet != other.questionSet || termsAndConditions != other.termsAndConditions || + renderAs != other.renderAs || + nextText != other.nextText || + successButtonText != other.successButtonText || + introButtonText != other.introButtonText || disclaimerText != other.disclaimerText -> false else -> true } @@ -101,7 +110,6 @@ internal class SurveyInteraction( override fun hashCode(): Int { var result = name?.hashCode() ?: 0 result = 31 * result + (description?.hashCode() ?: 0) - result = 31 * result + (submitText?.hashCode() ?: 0) result = 31 * result + (requiredText?.hashCode() ?: 0) result = 31 * result + (validationError?.hashCode() ?: 0) result = 31 * result + showSuccessMessage.hashCode() @@ -111,7 +119,7 @@ internal class SurveyInteraction( result = 31 * result + (closeConfirmCloseText?.hashCode() ?: 0) result = 31 * result + (closeConfirmBackText?.hashCode() ?: 0) result = 31 * result + isRequired.hashCode() - result = 31 * result + questions.hashCode() + result = 31 * result + questionSet.hashCode() result = 31 * result + (termsAndConditions?.hashCode() ?: 0) result = 31 * result + (disclaimerText?.hashCode() ?: 0) return result diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/interaction/SurveyInteractionTypeConverter.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/interaction/SurveyInteractionTypeConverter.kt index ae6b11ff..1fd388a9 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/interaction/SurveyInteractionTypeConverter.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/interaction/SurveyInteractionTypeConverter.kt @@ -5,6 +5,7 @@ import apptentive.com.android.feedback.engagement.interactions.InteractionData import apptentive.com.android.feedback.engagement.interactions.InteractionTypeConverter import apptentive.com.android.feedback.survey.interaction.SurveyInteraction.TermsAndConditions import apptentive.com.android.util.getList +import apptentive.com.android.util.getString import apptentive.com.android.util.optBoolean import apptentive.com.android.util.optMap import apptentive.com.android.util.optString @@ -17,7 +18,6 @@ internal class SurveyInteractionTypeConverter : InteractionTypeConverter>).map(::convertInvocation), + questions = configuration.getList("questions").map { + it as SurveyQuestionConfiguration + }, + buttonText = configuration.optString("button_text") ?: if (isPaged) "Next" else "Submit", + shouldContinue = getInvokeBehavior(configuration.optString("behavior") ?: "end"), + ) + } + + private fun getInvokeBehavior(value: String): Boolean { + return value == "continue" + } + + @Suppress("UNCHECKED_CAST") + private fun convertInvocation(config: Map) = InvocationData( + interactionId = config.getOrElse("next_question_set_id") { "" }.toString(), + criteria = config.getMap("criteria") as Map + ) +} diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/MultiChoiceQuestion.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/MultiChoiceQuestion.kt index 87583112..b05677e6 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/MultiChoiceQuestion.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/MultiChoiceQuestion.kt @@ -1,8 +1,5 @@ package apptentive.com.android.feedback.survey.model -import androidx.annotation.VisibleForTesting - -@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) internal class MultiChoiceQuestion( id: String, title: String, diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/QuestionListSubject.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/QuestionListSubject.kt new file mode 100644 index 00000000..4f3ca7e3 --- /dev/null +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/QuestionListSubject.kt @@ -0,0 +1,59 @@ +package apptentive.com.android.feedback.survey.model + +import androidx.annotation.WorkerThread +import apptentive.com.android.core.BehaviorSubject + +/** + * Reactive stream for storing survey questions with caching + */ +internal class QuestionListSubject( + questions: List> +) : BehaviorSubject>>(emptyList()) { + // We store a mutable list to avoid extra allocations + private var cachedList = questions.toMutableList() + + init { + // set cached list as initial value + value = cachedList + } + + fun updateCachedList(questions: List>) { + cachedList = questions.toMutableList() + } + + @WorkerThread + fun updateAnswer(questionId: String, answer: T) { + /* + In the traditional reactive approach every object is readonly + which means you would need to create a copy of everything what + changed. + + The code would look like this: + + > val newList = value.toMutableList() + > + > // find the question index + > val index = value.indexOfFirst { it.id == questionId } + > + > // make a copy + > newList[index] = value[index].copy(answer = answer) + > + > // let observers know that question list has changed + > value = newList + + But in this case we decided to introduce some caching for + performance reasons (avoid unnecessary allocations). This should be + fine as long as we only modify the model on a dedicated thread. + */ + + // find the question index + val index = cachedList.indexOfFirst { it.id == questionId } + + // update question's answer + @Suppress("UNCHECKED_CAST") + (cachedList[index] as SurveyQuestion).answer = answer + + // let observers know that question list has changed + value = cachedList + } +} diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/RangeQuestion.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/RangeQuestion.kt index 3ff4707f..95f07609 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/RangeQuestion.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/RangeQuestion.kt @@ -1,8 +1,5 @@ package apptentive.com.android.feedback.survey.model -import androidx.annotation.VisibleForTesting - -@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) internal class RangeQuestion( id: String, title: String, diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SingleLineQuestion.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SingleLineQuestion.kt index 200ae7ae..eebc75aa 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SingleLineQuestion.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SingleLineQuestion.kt @@ -1,8 +1,5 @@ package apptentive.com.android.feedback.survey.model -import androidx.annotation.VisibleForTesting - -@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) internal class SingleLineQuestion( id: String, title: String, @@ -22,13 +19,13 @@ internal class SingleLineQuestion( instructionsText = instructionsText, answer = answer ?: Answer() ) { - data class Answer(val value: String? = null) : SurveyQuestionAnswer + data class Answer(val value: String = "") : SurveyQuestionAnswer - val answerString: String? get() = answer.value + val answerString: String get() = answer.value - override fun isValidAnswer(answer: Answer) = !answer.value.isNullOrBlank() + override fun isValidAnswer(answer: Answer) = answer.value.isNotBlank() - override fun isAnswered(answer: Answer) = !answer.value.isNullOrEmpty() + override fun isAnswered(answer: Answer) = answer.value.isNotBlank() //region Equality diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyAnswerState.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyAnswerState.kt new file mode 100644 index 00000000..3b91c9fa --- /dev/null +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyAnswerState.kt @@ -0,0 +1,7 @@ +package apptentive.com.android.feedback.survey.model + +internal sealed class SurveyAnswerState { + object Skipped : SurveyAnswerState() + object Empty : SurveyAnswerState() + data class Answered(val answer: SurveyQuestionAnswer) : SurveyAnswerState() +} diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyModel.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyModel.kt index 04ac149e..b4001f0a 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyModel.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyModel.kt @@ -2,45 +2,100 @@ package apptentive.com.android.feedback.survey.model import android.text.Spanned import androidx.annotation.WorkerThread -import apptentive.com.android.core.BehaviorSubject -import apptentive.com.android.core.Observable -import apptentive.com.android.util.InternalUseOnly - -@InternalUseOnly -class SurveyModel( +import apptentive.com.android.core.DependencyProvider +import apptentive.com.android.core.ResettableDelegate +import apptentive.com.android.feedback.engagement.EngagementContext +import apptentive.com.android.feedback.engagement.EngagementContextFactory +import apptentive.com.android.feedback.model.InvocationData +import apptentive.com.android.feedback.survey.interaction.DefaultSurveyQuestionConverter +import apptentive.com.android.feedback.survey.model.SurveyPageData.PageIndicatorStatus +import apptentive.com.android.feedback.survey.utils.END_OF_QUESTION_SET +import apptentive.com.android.feedback.survey.utils.UNSET_QUESTION_SET +import apptentive.com.android.util.isNotNullOrEmpty + +internal class SurveyModel( val interactionId: String, - questions: List>, + val questionSet: List, val name: String?, - val description: String?, - val submitText: String?, - val requiredText: String?, + val surveyIntroduction: String?, + val introButtonText: String?, + val submitText: String, + val requiredText: String, val validationError: String?, - val showSuccessMessage: Boolean, - val successMessage: String?, + var showSuccessMessage: Boolean, + var successMessage: String?, + val successButtonText: String?, + val termsAndConditionsLinkText: Spanned?, + val disclaimerText: String?, + val renderAs: RenderAs, val closeConfirmTitle: String?, val closeConfirmMessage: String?, val closeConfirmCloseText: String?, val closeConfirmBackText: String?, - val termsAndConditionsLinkText: Spanned?, - val disclaimerText: String? ) { - private val questionsSubject = QuestionListSubject(questions) // BehaviourSubject>> - val questionsStream: Observable>> = questionsSubject - val questions: List> get() = questionsSubject.value + private val pages: MutableMap = mutableMapOf() + internal lateinit var currentPageID: String + private val singlePageID: String = "single" + internal val introPageID: String = "intro" + val successPageID: String = "success" - val allRequiredAnswersAreValid get() = getFirstInvalidRequiredQuestionIndex() == -1 + init { + when (renderAs) { + RenderAs.LIST -> setListSurveyPage() + RenderAs.PAGED -> { + setIntroPage() + setSuccessPage() + setQuestionsPage() + } + } + } + + val questionListSubject: QuestionListSubject = + QuestionListSubject(getCurrentPage().questions) // BehaviorSubject + val currentQuestions: List> get() = questionListSubject.value - val hasAnyAnswer get() = questions.any { it.hasAnswer } + var nextQuestionSetId: String by ResettableDelegate(UNSET_QUESTION_SET) { + val nextPageID = getNextQuestionSet() + if (nextPageID.isNullOrEmpty()) END_OF_QUESTION_SET else nextPageID + } + + fun getCurrentPage(): SurveyPageData { + return pages[currentPageID] ?: throw IllegalStateException("Current page cannot be null") + } + + fun getNextQuestionSet(): String? { + val context: EngagementContext = + DependencyProvider.of().engagementContext() + + val nextQuestionSet = context.getNextQuestionSet(getCurrentPage().invocations) + + return if (nextQuestionSet.isNullOrEmpty()) { + pages.takeIf { it.contains(successPageID) && currentPageID != successPageID }?.let { successPageID } + } else { + nextQuestionSet + } + } + + @WorkerThread + fun goToNextPage() { + currentPageID = nextQuestionSetId + nextQuestionSetId = UNSET_QUESTION_SET + val currentPage = pages[currentPageID] + if (currentPage != null) { + questionListSubject.value = currentPage.questions + questionListSubject.updateCachedList(currentPage.questions) + } + } @WorkerThread fun updateAnswer(questionId: String, answer: T) { - questionsSubject.updateAnswer(questionId, answer) + questionListSubject.updateAnswer(questionId, answer) } @WorkerThread fun > getQuestion(questionId: String): T { - val question = questions.find { question -> + val question = currentQuestions.find { question -> question.id == questionId } ?: throw IllegalArgumentException("Question not found: $questionId") @@ -48,60 +103,103 @@ class SurveyModel( return question as T } - /** Returns the index of the first REQUIRED invalid question/NOT REQUIRED but cannot submit - * (or -1 if all required questions are valid) */ - fun getFirstInvalidRequiredQuestionIndex(): Int { - return questions.indexOfFirst { (it.isRequired && !it.hasValidAnswer) || !it.canSubmitOptionalQuestion } + fun getAllQuestionsInTheSurvey(): List> { + return questionSet.flatMap { questionSet -> + questionSet.questions.map { config -> + DefaultSurveyQuestionConverter().convert( + config = config, + requiredTextMessage = requiredText + ) + } + } } -} -/** - * Reactive stream for storing survey questions with caching - */ -private class QuestionListSubject( - questions: List> -) : BehaviorSubject>>(emptyList()) { - // We store a mutable list to avoid extra allocations - private val cachedList = questions.toMutableList() + private fun setListSurveyPage() { + val questions = getAllQuestionsInTheSurvey() + val singlePage = SurveyPageData( + singlePageID, + surveyIntroduction, + disclaimerText, + if (showSuccessMessage) successMessage else null, + questions, + PageIndicatorStatus.HIDE.toInt(), + submitText, + listOf(), + ) + pages[singlePageID] = singlePage + currentPageID = singlePageID + } - init { - // set cached list as initial value - value = cachedList + private fun setIntroPage() { + val firstQuestionSetID = questionSet.firstOrNull()?.id.orEmpty() + + if (surveyIntroduction.isNotNullOrEmpty() || disclaimerText.isNotNullOrEmpty()) { + val introPage = SurveyPageData( + introPageID, + surveyIntroduction, + disclaimerText, + null, + listOf(), + PageIndicatorStatus.SHOW_NO_PROGRESS.toInt(), + introButtonText, + listOf(InvocationData(firstQuestionSetID, mapOf())), + ) + pages[introPageID] = introPage + currentPageID = introPageID + } else { + currentPageID = firstQuestionSetID + } } - @WorkerThread - fun updateAnswer(questionId: String, answer: T) { - /* - In the traditional reactive approach every object is readonly - which means you would need to create a copy of everything what - changed. - - The code would look like this: - - > val newList = value.toMutableList() - > - > // find the question index - > val index = value.indexOfFirst { it.id == questionId } - > - > // make a copy - > newList[index] = value[index].copy(answer = answer) - > - > // let observers know that question list has changed - > value = newList - - But in this case we decided to introduce some caching for - performance reasons (avoid unnecessary allocations). This should be - fine as long as we only modify the model on a dedicated thread. - */ - - // find the question index - val index = cachedList.indexOfFirst { it.id == questionId } - - // update question's answer - @Suppress("UNCHECKED_CAST") - (cachedList[index] as SurveyQuestion).answer = answer + private fun setSuccessPage() { + val successMessageDescription = successMessage + val successButtonText = this.successButtonText + + if (showSuccessMessage && + successMessageDescription?.isNotEmpty() == true && + successButtonText?.isNotEmpty() == true + ) { + val successPage = SurveyPageData( + successPageID, + null, + disclaimerText, + successMessageDescription, + listOf(), + PageIndicatorStatus.HIDE.toInt(), + successButtonText, + listOf(), + ) + pages[successPageID] = successPage + } + } - // let observers know that question list has changed - value = cachedList + private fun setQuestionsPage() { + questionSet.forEachIndexed { index, questionSet -> + val questions: List> = questionSet.questions.map { config -> + DefaultSurveyQuestionConverter().convert( + config = config, + requiredTextMessage = requiredText + ) + } + + val invocations: List = questionSet.invokes + + val questionPage = SurveyPageData( + questionSet.id, + null, + null, + null, + questions, + index, + questionSet.buttonText, + invocations, + ) + pages[questionPage.id] = questionPage + } } } + +enum class RenderAs { + PAGED, + LIST +} diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyPageData.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyPageData.kt new file mode 100644 index 00000000..fd18c461 --- /dev/null +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyPageData.kt @@ -0,0 +1,26 @@ +package apptentive.com.android.feedback.survey.model + +import apptentive.com.android.feedback.model.InvocationData + +internal data class SurveyPageData( + val id: String, + val introductionText: String?, + val disclaimerText: String?, + val successText: String?, + val questions: List>, + val pageIndicatorValue: Int?, + val advanceActionLabel: String?, + val invocations: List, +) { + enum class PageIndicatorStatus { + HIDE, + SHOW_NO_PROGRESS; + + fun toInt(): Int? { + return when (this) { + HIDE -> null + SHOW_NO_PROGRESS -> -1 + } + } + } +} diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyQuestion.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyQuestion.kt index cd96cf5e..c3ca4a4f 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyQuestion.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyQuestion.kt @@ -1,6 +1,6 @@ package apptentive.com.android.feedback.survey.model -import androidx.annotation.VisibleForTesting +import apptentive.com.android.util.InternalUseOnly /** * Model class to represent survey questions. @@ -11,7 +11,7 @@ import androidx.annotation.VisibleForTesting * @param instructionsText the text to display as an optional instruction (for example, "Select one") * @param validationError a textual error message that is read by a screen reader when a question fails to validate */ -@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) +@InternalUseOnly abstract class SurveyQuestion( val id: String, val title: String, diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyQuestionSet.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyQuestionSet.kt new file mode 100644 index 00000000..cda5a92f --- /dev/null +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyQuestionSet.kt @@ -0,0 +1,21 @@ +package apptentive.com.android.feedback.survey.model + +import apptentive.com.android.feedback.model.InvocationData +import apptentive.com.android.feedback.survey.interaction.SurveyInteraction +import apptentive.com.android.feedback.survey.interaction.SurveyQuestionConfiguration + +/** + * Model class to represent survey question set. + * @param id a string that uniquely identifies the question set + * @param invokes [SurveyInteraction.Invocation] object with criteria for the next question ID + * @param questions list of [SurveyQuestion] + * @param buttonText button text on the question page + */ + +internal data class SurveyQuestionSet( + val id: String, + val invokes: List, + val questions: List, + val buttonText: String, + val shouldContinue: Boolean +) diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyResponsePayload.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyResponsePayload.kt index e4eb83fd..cbec633f 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyResponsePayload.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/model/SurveyResponsePayload.kt @@ -10,12 +10,20 @@ import apptentive.com.android.feedback.payload.PayloadType import apptentive.com.android.network.HttpMethod import apptentive.com.android.util.generateUUID +private const val SKIPPED_QUESTION = "skipped" +private const val EMPTY_QUESTION = "empty" +private const val ANSWERED_QUESTION = "answered" @Keep internal class SurveyResponsePayload( nonce: String = generateUUID(), val id: String, - val answers: Map> + val answers: Map ) : ConversationPayload(nonce) { + + data class AnswerStateData( + val state: String, + val value: List? = null + ) data class AnswerData( val id: String? = null, val value: Any? = null @@ -38,19 +46,29 @@ internal class SurveyResponsePayload( companion object { fun fromAnswers( id: InteractionId, - answers: Map + answers: Map ) = SurveyResponsePayload( id = id, - answers = answers - .map { (id, answer) -> id to convertAnswer(answer) } - .toMap() + answers = buildAnswerStateData(answers) ) - private fun convertAnswer(answer: SurveyQuestionAnswer) = - when (answer) { - is SingleLineQuestion.Answer -> listOf(AnswerData(value = answer.value)) - is RangeQuestion.Answer -> listOf(AnswerData(value = answer.selectedIndex)) - is MultiChoiceQuestion.Answer -> answer.choices.mapNotNull { if (it.checked) AnswerData(it.id, it.value) else null } + private fun buildAnswerStateData(answers: Map) = + answers.map { (id, answers) -> + id to when (answers) { + is SurveyAnswerState.Answered -> AnswerStateData( + state = ANSWERED_QUESTION, + value = convertAnswer(answers) + ) + is SurveyAnswerState.Empty -> AnswerStateData(state = EMPTY_QUESTION) + is SurveyAnswerState.Skipped -> AnswerStateData(state = SKIPPED_QUESTION) + } + }.toMap() + + private fun convertAnswer(answer: SurveyAnswerState.Answered) = + when (answer.answer) { + is SingleLineQuestion.Answer -> listOf(AnswerData(value = answer.answer.value)) + is RangeQuestion.Answer -> listOf(AnswerData(value = answer.answer.selectedIndex)) + is MultiChoiceQuestion.Answer -> answer.answer.choices.mapNotNull { if (it.checked) AnswerData(it.id, it.value) else null } else -> throw IllegalArgumentException("Unexpected type: ${answer::class.java}") } } diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/utils/SurveyViewModelUtils.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/utils/SurveyViewModelUtils.kt index 7ce25275..f775dd5a 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/utils/SurveyViewModelUtils.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/utils/SurveyViewModelUtils.kt @@ -11,23 +11,31 @@ import apptentive.com.android.feedback.survey.SurveyModelFactory import apptentive.com.android.feedback.survey.model.MultiChoiceQuestion import apptentive.com.android.feedback.survey.model.RangeQuestion import apptentive.com.android.feedback.survey.model.SingleLineQuestion +import apptentive.com.android.feedback.survey.model.SurveyAnswerState import apptentive.com.android.feedback.survey.model.SurveyModel -import apptentive.com.android.feedback.survey.model.SurveyQuestionAnswer +import apptentive.com.android.feedback.survey.model.SurveyQuestion import apptentive.com.android.feedback.survey.model.SurveyResponsePayload import apptentive.com.android.feedback.survey.viewmodel.SurveyViewModel import apptentive.com.android.feedback.utils.getInteractionBackup +import apptentive.com.android.util.MissingKeyException +import kotlin.jvm.Throws private const val EVENT_SUBMIT = "submit" private const val EVENT_CANCEL = "cancel" private const val EVENT_CANCEL_PARTIAL = "cancel_partial" private const val EVENT_CLOSE = "close" private const val EVENT_CONTINUE_PARTIAL = "continue_partial" +internal const val UNSET_QUESTION_SET = "unset" +internal const val END_OF_QUESTION_SET = "end_question_set" +@Throws(MissingKeyException::class) internal fun createSurveyViewModel( context: EngagementContext = DependencyProvider.of().engagementContext() ): SurveyViewModel { return try { createSurveyViewModel(DependencyProvider.of().getSurveyModel(), context) + } catch (exception: MissingKeyException) { + throw MissingKeyException("Survey interaction is missing required keys $exception") } catch (exception: Exception) { createSurveyViewModel( DefaultSurveyModelFactory( @@ -57,6 +65,17 @@ private fun createSurveyViewModel( interactionResponses = mapAnswersToResponses(answers) ) }, + recordCurrentAnswer = { answers -> + context.engageToRecordCurrentAnswer( + interactionResponses = mapAnswersToResponses(answers) + ) + }, + resetCurrentAnswer = { answers -> + context.engageToRecordCurrentAnswer( + interactionResponses = mapAnswersToResponses(answers), + reset = true + ) + }, onCancel = { context.engage( event = Event.internal(EVENT_CANCEL, interaction = InteractionType.Survey), @@ -83,12 +102,14 @@ private fun createSurveyViewModel( } ) -private fun mapAnswersToResponses(answers: Map): Map> { - return answers.map { answer -> - answer.key to when (answer.value) { +internal fun mapAnswersToResponses(answers: Map): Map> { + + val questionsAnswered = answers.filter { it.value is SurveyAnswerState.Answered } + + return questionsAnswered.map { item -> + item.key to when (val answer = (item.value as SurveyAnswerState.Answered).answer) { is MultiChoiceQuestion.Answer -> { - val responses = answer.value as MultiChoiceQuestion.Answer - responses.choices.mapNotNull { + answer.choices.mapNotNull { if (it.checked) { if (it.value != null) InteractionResponse.OtherResponse(it.id, it.value) else InteractionResponse.IdResponse(it.id) @@ -96,16 +117,20 @@ private fun mapAnswersToResponses(answers: Map): M }.toSet() } is SingleLineQuestion.Answer -> { - val response = answer.value as SingleLineQuestion.Answer - // Should never be null at this point - response.value?.let { setOf(InteractionResponse.StringResponse(it)) } ?: emptySet() + if (answer.value.isNotEmpty()) + setOf(InteractionResponse.StringResponse(answer.value)) + else + emptySet() } is RangeQuestion.Answer -> { - val response = answer.value as RangeQuestion.Answer // Should never be null at this point - response.selectedIndex?.let { setOf(InteractionResponse.LongResponse(it.toLong())) } ?: emptySet() + answer.selectedIndex?.let { setOf(InteractionResponse.LongResponse(it.toLong())) } ?: emptySet() } else -> emptySet() // Should not happen } }.toMap() } + +internal fun getValidAnsweredQuestions(shownQuestions: List>): List> { + return shownQuestions.filter { it.hasValidAnswer && it.hasAnswer } +} diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/view/SurveyQuestionContainerView.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/view/SurveyQuestionContainerView.kt index 008d6f60..f89e068f 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/view/SurveyQuestionContainerView.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/view/SurveyQuestionContainerView.kt @@ -6,20 +6,20 @@ import android.text.util.Linkify import android.util.AttributeSet import android.view.LayoutInflater import android.view.View -import android.widget.FrameLayout import android.widget.LinearLayout import androidx.core.view.isVisible import apptentive.com.android.feedback.survey.R import apptentive.com.android.ui.getThemeColor import apptentive.com.android.util.Log -import apptentive.com.android.util.LogTags +import apptentive.com.android.util.LogTags.SURVEY import com.google.android.material.textview.MaterialTextView internal class SurveyQuestionContainerView( context: Context, attrs: AttributeSet?, - defStyleAttr: Int -) : FrameLayout(context, attrs, defStyleAttr) { + defStyleAttr: Int, + isPaged: Boolean +) : LinearLayout(context, attrs, defStyleAttr) { private val questionLayout: LinearLayout private val titleTextView: MaterialTextView private val instructionsTextView: MaterialTextView @@ -50,12 +50,16 @@ internal class SurveyQuestionContainerView( private val errorColor: Int constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0, false) init { val contentView = LayoutInflater.from(context) .inflate(R.layout.apptentive_survey_question_container, this, true) + if (isPaged) { + contentView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + questionLayout = contentView.findViewById(R.id.apptentive_question_layout) titleTextView = contentView.findViewById(R.id.apptentive_question_title) instructionsTextView = contentView.findViewById(R.id.apptentive_question_instructions) @@ -65,7 +69,7 @@ internal class SurveyQuestionContainerView( try { Linkify.addLinks(titleTextView, Linkify.ALL) } catch (exception: Exception) { - Log.e(LogTags.MESSAGE_CENTER, "Couldn't add linkify to survey title text", exception) + Log.e(SURVEY, "Couldn't add linkify to survey title text", exception) } titleTextViewDefaultColor = titleTextView.textColors instructionsTextViewDefaultColor = instructionsTextView.textColors diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/view/SurveyQuestionViewHolderFactory.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/view/SurveyQuestionViewHolderFactory.kt index f5922b0f..c2b48974 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/view/SurveyQuestionViewHolderFactory.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/view/SurveyQuestionViewHolderFactory.kt @@ -7,14 +7,14 @@ import apptentive.com.android.ui.ViewHolderFactory internal class SurveyQuestionViewHolderFactory( private val layoutId: Int, + private val isPaged: Boolean, private val viewHolderCreator: (SurveyQuestionContainerView) -> SurveyQuestionListItem.ViewHolder<*> ) : ViewHolderFactory { override fun createItemView(parent: ViewGroup): View { - val containerView = SurveyQuestionContainerView(parent.context) + val containerView = SurveyQuestionContainerView(parent.context, null, 0, isPaged) containerView.setAnswerView(layoutId) return containerView } - override fun createViewHolder(itemView: View) = - viewHolderCreator(itemView as SurveyQuestionContainerView) + override fun createViewHolder(itemView: View) = viewHolderCreator(itemView as SurveyQuestionContainerView) } diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/view/SurveySegmentedProgressBar.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/view/SurveySegmentedProgressBar.kt new file mode 100644 index 00000000..53c039c5 --- /dev/null +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/view/SurveySegmentedProgressBar.kt @@ -0,0 +1,85 @@ +package apptentive.com.android.feedback.survey.view + +import android.content.Context +import android.content.res.TypedArray +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.appcompat.content.res.AppCompatResources +import androidx.transition.ChangeBounds +import androidx.transition.TransitionManager +import apptentive.com.android.feedback.survey.R +import apptentive.com.android.util.Log +import apptentive.com.android.util.LogTags + +internal class SurveySegmentedProgressBar(context: Context, attrs: AttributeSet? = null) : LinearLayout(context, attrs) { + + private val progressBar: LinearLayout + private var previousIcon: Drawable? + private var currentIcon: Drawable? + private var nextIcon: Drawable? + + init { + LayoutInflater.from(context) + .inflate(R.layout.apptentive_survey_segmented_progress_bar, this, true) + progressBar = findViewById(R.id.apptentive_progress_bar_pill_layout) + + val progressIcons: TypedArray = context.obtainStyledAttributes( + intArrayOf( + R.attr.apptentiveProgressBarPreviousIcon, + R.attr.apptentiveProgressBarCurrentIcon, + R.attr.apptentiveProgressBarNextIcon + ) + ) + + try { + previousIcon = progressIcons.getDrawable(0) + currentIcon = progressIcons.getDrawable(1) + nextIcon = progressIcons.getDrawable(2) + } catch (e: Exception) { + Log.e(LogTags.INTERACTIONS, "Error loading progress bar icons. Reverting to default", e) + + previousIcon = AppCompatResources.getDrawable(context, R.drawable.apptentive_pill_previous) + currentIcon = AppCompatResources.getDrawable(context, R.drawable.apptentive_pill_current) + nextIcon = AppCompatResources.getDrawable(context, R.drawable.apptentive_pill_next) + } finally { + progressIcons.recycle() + } + } + + fun setSegmentCount(count: Int) { + // Giving this a horizontal margin based on how many segments so the sizing of the + // individual segments are more reasonable. + val PROGRESS_BAR_HORIZONTAL_MARGIN = 350 + val params = progressBar.layoutParams as MarginLayoutParams + params.marginStart = PROGRESS_BAR_HORIZONTAL_MARGIN / count + params.marginEnd = PROGRESS_BAR_HORIZONTAL_MARGIN / count + progressBar.layoutParams = params + + for (i in 0 until count) { + val segment = LayoutInflater.from(context) + .inflate(R.layout.apptentive_survey_segmented_progress_bar_item, progressBar, false) + segment.background = nextIcon + progressBar.addView(segment) + } + } + + fun updateProgress(currentQuestion: Int) { + if (currentQuestion < 0) return + + val transition = ChangeBounds() + transition.duration = 300 + TransitionManager.beginDelayedTransition(progressBar, transition) + + if (currentQuestion > 0) { + for (i in 0 until currentQuestion) { + val previousSegment = progressBar.getChildAt(i) + previousSegment.background = previousIcon + } + } + + val currentSegment = progressBar.getChildAt(currentQuestion) + currentSegment.background = currentIcon + } +} diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/MultiChoiceQuestionListItem.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/MultiChoiceQuestionListItem.kt index 866c134c..da063d59 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/MultiChoiceQuestionListItem.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/MultiChoiceQuestionListItem.kt @@ -1,8 +1,8 @@ package apptentive.com.android.feedback.survey.viewmodel import android.view.LayoutInflater -import android.view.ViewGroup import android.widget.CompoundButton +import android.widget.LinearLayout import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import apptentive.com.android.feedback.survey.R @@ -96,7 +96,7 @@ internal class MultiChoiceQuestionListItem( itemView: SurveyQuestionContainerView, private val onSelectionChanged: (questionId: String, choiceId: String, selected: Boolean, text: String?) -> Unit ) : SurveyQuestionListItem.ViewHolder(itemView) { - private val choiceContainer: ViewGroup = itemView.findViewById(R.id.apptentive_choice_container) + private val choiceContainer = itemView.findViewById(R.id.apptentive_choice_container) private lateinit var cachedViews: List override fun bindView( diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SingleLineQuestionListItem.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SingleLineQuestionListItem.kt index 09a1ffe0..cb063113 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SingleLineQuestionListItem.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SingleLineQuestionListItem.kt @@ -18,8 +18,7 @@ import com.google.android.material.textfield.TextInputLayout * @param id question id * @param title question title * @param instructions optional instructions text (for example, "Required") - * @param validationError contains validation error message in case if the question has an invalid - * answer or null if the answer is valid. + * @param validationError contains validation error message in case if the question has an invalid answer or `null` if the answer is valid. * @param text user answer text * @param freeFormHint hint text to be displayed if user provided no answer * @param multiline indicates if the answer field should occupy more than a single line @@ -29,7 +28,7 @@ internal class SingleLineQuestionListItem( title: String, instructions: String? = null, validationError: String? = null, - val text: String? = null, + val text: String = "", val freeFormHint: String? = null, val multiline: Boolean = false ) : SurveyQuestionListItem( @@ -74,6 +73,7 @@ internal class SingleLineQuestionListItem( //region View Holder class ViewHolder( itemView: SurveyQuestionContainerView, + private val isPaged: Boolean = false, val onTextChanged: (id: String, text: String) -> Unit ) : SurveyQuestionListItem.ViewHolder(itemView) { private val answerTextInputLayout: TextInputLayout = itemView.findViewById(R.id.apptentive_answer_text_input_layout) @@ -107,6 +107,13 @@ internal class SingleLineQuestionListItem( answerEditText.doAfterTextChanged { onTextChanged(questionId, it?.toString().orEmpty().trim()) } + + // Fix for ViewPager adapter updating the view on error and resetting the cursor position + if (isPaged) { + answerEditText.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) updateValidationError(null) + } + } } override fun updateValidationError(errorMessage: String?) { diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyFooterListItem.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyFooterListItem.kt index 04cad97e..6623cd52 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyFooterListItem.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyFooterListItem.kt @@ -4,7 +4,7 @@ import android.view.View import android.widget.Toast import android.widget.Toast.LENGTH_SHORT import apptentive.com.android.feedback.survey.R -import apptentive.com.android.ui.ListViewAdapter +import apptentive.com.android.ui.ApptentiveViewHolder import apptentive.com.android.ui.ListViewItem import com.google.android.material.button.MaterialButton import com.google.android.material.textview.MaterialTextView @@ -64,7 +64,7 @@ internal class SurveyFooterListItem( class ViewHolder( itemView: View, private val submitCallback: () -> Unit - ) : ListViewAdapter.ViewHolder(itemView) { + ) : ApptentiveViewHolder(itemView) { private val submitButton = itemView.findViewById(R.id.apptentive_submit_button) private val errorMessageView = itemView.findViewById(R.id.apptentive_submit_error_message) private val disclaimerTextView = itemView.findViewById(R.id.apptentive_disclaimer_text) diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyHeaderListItem.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyHeaderListItem.kt index 40d85898..c940371d 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyHeaderListItem.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyHeaderListItem.kt @@ -4,10 +4,10 @@ import android.text.method.LinkMovementMethod import android.text.util.Linkify import android.view.View import apptentive.com.android.feedback.survey.R -import apptentive.com.android.ui.ListViewAdapter +import apptentive.com.android.ui.ApptentiveViewHolder import apptentive.com.android.ui.ListViewItem import apptentive.com.android.util.Log -import apptentive.com.android.util.LogTags +import apptentive.com.android.util.LogTags.SURVEY import com.google.android.material.textview.MaterialTextView internal class SurveyHeaderListItem(val instructions: String) : SurveyListItem( @@ -38,7 +38,7 @@ internal class SurveyHeaderListItem(val instructions: String) : SurveyListItem( return "${javaClass.simpleName}(instructions=$instructions)" } - class ViewHolder(itemView: View) : ListViewAdapter.ViewHolder(itemView) { + class ViewHolder(itemView: View) : ApptentiveViewHolder(itemView) { private val introductionView = itemView.findViewById(R.id.apptentive_survey_introduction) override fun bindView(item: SurveyHeaderListItem, position: Int) { @@ -47,7 +47,7 @@ internal class SurveyHeaderListItem(val instructions: String) : SurveyListItem( Linkify.addLinks(introductionView, Linkify.ALL) introductionView.movementMethod = LinkMovementMethod.getInstance() } catch (exception: Exception) { - Log.e(LogTags.MESSAGE_CENTER, "Couldn't add linkify to survey introduction text", exception) + Log.e(SURVEY, "Couldn't add linkify to survey introduction text", exception) } } } diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyIntroductionPageItem.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyIntroductionPageItem.kt new file mode 100644 index 00000000..f2b99e1b --- /dev/null +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyIntroductionPageItem.kt @@ -0,0 +1,64 @@ +package apptentive.com.android.feedback.survey.viewmodel + +import android.text.method.LinkMovementMethod +import android.text.util.Linkify +import android.view.View +import apptentive.com.android.feedback.survey.R +import apptentive.com.android.ui.ApptentiveViewHolder +import apptentive.com.android.ui.ListViewItem +import apptentive.com.android.util.Log +import apptentive.com.android.util.LogTags.SURVEY +import com.google.android.material.textview.MaterialTextView + +internal class SurveyIntroductionPageItem(val introduction: String, val disclaimer: String) : SurveyListItem( + id = "introduction", + type = Type.Introduction +) { + override fun getChangePayloadMask(oldItem: ListViewItem): Int { + return 0 // this item never changes dynamically + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SurveyIntroductionPageItem) return false + if (!super.equals(other)) return false + + if (introduction != other.introduction) return false + if (disclaimer != other.disclaimer) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + introduction.hashCode() + result = 31 * result + disclaimer.hashCode() + return result + } + + override fun toString(): String { + return "${javaClass.simpleName}(introduction=$introduction, disclaimer=$disclaimer)" + } + + class ViewHolder(itemView: View) : ApptentiveViewHolder(itemView) { + private val introductionView = itemView.findViewById(R.id.apptentive_survey_introduction) + private val disclaimerView = itemView.findViewById(R.id.apptentive_survey_disclaimer) + + override fun bindView(item: SurveyIntroductionPageItem, position: Int) { + introductionView.text = item.introduction + disclaimerView.text = item.disclaimer + + if (item.introduction.isBlank()) introductionView.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + if (item.disclaimer.isBlank()) disclaimerView.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + + try { + Linkify.addLinks(introductionView, Linkify.ALL) + introductionView.movementMethod = LinkMovementMethod.getInstance() + Linkify.addLinks(disclaimerView, Linkify.ALL) + disclaimerView.movementMethod = LinkMovementMethod.getInstance() + } catch (exception: Exception) { + Log.e(SURVEY, "Couldn't add linkify to survey introduction or disclaimer text", exception) + } + } + } +} diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyListItem.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyListItem.kt index 3352aa28..338e6b84 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyListItem.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyListItem.kt @@ -1,5 +1,6 @@ package apptentive.com.android.feedback.survey.viewmodel +import apptentive.com.android.ui.ApptentivePagerAdapter import apptentive.com.android.ui.ListViewAdapter import apptentive.com.android.ui.ListViewItem import apptentive.com.android.ui.ViewHolderFactory @@ -11,7 +12,9 @@ import apptentive.com.android.ui.ViewHolderFactory abstract class SurveyListItem(id: String, type: Type) : ListViewItem(id, type.ordinal) { enum class Type { Header, + Introduction, Footer, + Success, SingleLineQuestion, RangeQuestion, MultiChoiceQuestion @@ -20,3 +23,6 @@ abstract class SurveyListItem(id: String, type: Type) : ListViewItem(id, type.or internal fun ListViewAdapter.register(type: SurveyListItem.Type, factory: ViewHolderFactory) = register(type.ordinal, factory) + +internal fun ApptentivePagerAdapter.register(type: SurveyListItem.Type, factory: ViewHolderFactory) = + register(type.ordinal, factory) diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyQuestionListItem.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyQuestionListItem.kt index d2f95a93..217e2112 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyQuestionListItem.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyQuestionListItem.kt @@ -1,8 +1,10 @@ package apptentive.com.android.feedback.survey.viewmodel +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo import androidx.annotation.CallSuper import apptentive.com.android.feedback.survey.view.SurveyQuestionContainerView -import apptentive.com.android.ui.ListViewAdapter +import apptentive.com.android.ui.ApptentiveViewHolder import apptentive.com.android.ui.ListViewItem /** @@ -79,7 +81,7 @@ internal abstract class SurveyQuestionListItem( internal abstract class ViewHolder( itemView: SurveyQuestionContainerView - ) : ListViewAdapter.ViewHolder(itemView) { + ) : ApptentiveViewHolder(itemView) { private val containerView: SurveyQuestionContainerView = itemView private lateinit var _questionId: String protected val questionId get() = _questionId // disallow accidental modifications @@ -102,6 +104,12 @@ internal abstract class SurveyQuestionListItem( // validation error updateValidationError(item.validationError) + + // For Talkback: Sets focus to the question to read out error message + if (item.validationError != null) { + containerView.performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null) + containerView.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED) + } } @CallSuper diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveySuccessPageItem.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveySuccessPageItem.kt new file mode 100644 index 00000000..ebe24b59 --- /dev/null +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveySuccessPageItem.kt @@ -0,0 +1,63 @@ +package apptentive.com.android.feedback.survey.viewmodel + +import android.text.method.LinkMovementMethod +import android.text.util.Linkify +import android.view.View +import apptentive.com.android.feedback.survey.R +import apptentive.com.android.ui.ApptentiveViewHolder +import apptentive.com.android.ui.ListViewItem +import apptentive.com.android.util.Log +import apptentive.com.android.util.LogTags.SURVEY +import com.google.android.material.textview.MaterialTextView + +internal class SurveySuccessPageItem(val success: String, val disclaimer: String) : SurveyListItem( + id = "success", + type = Type.Success +) { + override fun getChangePayloadMask(oldItem: ListViewItem): Int { + return 0 // this item never changes dynamically + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SurveySuccessPageItem) return false + if (!super.equals(other)) return false + + if (success != other.success) return false + if (disclaimer != other.disclaimer) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + success.hashCode() + result = 31 * result + disclaimer.hashCode() + return result + } + + override fun toString(): String { + return "${javaClass.simpleName}(success=$success, disclaimer=$disclaimer)" + } + + class ViewHolder(itemView: View) : ApptentiveViewHolder(itemView) { + private val successView = itemView.findViewById(R.id.apptentive_survey_success) + private val disclaimerView = itemView.findViewById(R.id.apptentive_survey_disclaimer) + + override fun bindView(item: SurveySuccessPageItem, position: Int) { + successView.text = item.success + disclaimerView.text = item.disclaimer + + if (item.disclaimer.isBlank()) disclaimerView.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + + try { + Linkify.addLinks(successView, Linkify.ALL) + successView.movementMethod = LinkMovementMethod.getInstance() + Linkify.addLinks(disclaimerView, Linkify.ALL) + disclaimerView.movementMethod = LinkMovementMethod.getInstance() + } catch (exception: Exception) { + Log.e(SURVEY, "Couldn't add linkify to survey success or disclaimer text", exception) + } + } + } +} diff --git a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyViewModel.kt b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyViewModel.kt index f56a14c0..48bb3d47 100644 --- a/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyViewModel.kt +++ b/apptentive-survey/src/main/java/apptentive/com/android/feedback/survey/viewmodel/SurveyViewModel.kt @@ -10,16 +10,20 @@ import apptentive.com.android.core.LiveEvent import apptentive.com.android.core.asLiveData import apptentive.com.android.feedback.survey.model.MultiChoiceQuestion import apptentive.com.android.feedback.survey.model.RangeQuestion +import apptentive.com.android.feedback.survey.model.RenderAs import apptentive.com.android.feedback.survey.model.SingleLineQuestion +import apptentive.com.android.feedback.survey.model.SurveyAnswerState import apptentive.com.android.feedback.survey.model.SurveyModel import apptentive.com.android.feedback.survey.model.SurveyQuestion -import apptentive.com.android.feedback.survey.model.SurveyQuestionAnswer import apptentive.com.android.feedback.survey.model.update +import apptentive.com.android.feedback.survey.utils.END_OF_QUESTION_SET +import apptentive.com.android.feedback.survey.utils.getValidAnsweredQuestions +import apptentive.com.android.util.isNotNullOrEmpty /** * ViewModel for Surveys * - * SurveyViewModel class that is responsible for preparing and managing survey data + * [SurveyViewModel] class that is responsible for preparing and managing survey data * for BaseSurveyActivity * * @property model [SurveyModel] data model that represents the survey @@ -38,10 +42,12 @@ import apptentive.com.android.feedback.survey.model.update * when survey is resumed through after an attempt to close */ -class SurveyViewModel( +internal class SurveyViewModel( private val model: SurveyModel, private val executors: Executors, private val onSubmit: SurveySubmitCallback, + private val recordCurrentAnswer: RecordCurrentAnswerCallback, + private val resetCurrentAnswer: ResetCurrentAnswerCallback, private val onCancel: SurveyCancelCallback, private val onCancelPartial: SurveyCancelPartialCallback, private val onClose: SurveyCloseCallback, @@ -49,12 +55,18 @@ class SurveyViewModel( ) : ViewModel() { /** LiveData which transforms a list of {SurveyQuestion} into a list of {SurveyQuestionListItem} */ private val questionsStream: LiveData>> = - model.questionsStream.asLiveData() + model.questionListSubject.asLiveData() + + private val shownQuestions: MutableList> = mutableListOf() /** Holds an index to the first invalid question (or -1 if all questions are valid) */ private val firstInvalidQuestionIndexEvent = LiveEvent() val firstInvalidQuestionIndex: LiveData = firstInvalidQuestionIndexEvent + internal val allRequiredAnswersAreValid get() = getFirstInvalidRequiredQuestionIndex() == -1 + + private val hasAnyAnswer get() = model.currentQuestions.any { it.hasAnswer } + /** Live data which keeps track of the survey "submit" message (shown under the "submit" button) */ private val surveySubmitMessageState = MutableLiveData() @@ -63,8 +75,15 @@ class SurveyViewModel( questionListItemFactory = DefaultSurveyQuestionListItemFactory() ) - private val requiredTextEvent = LiveEvent() - val requiredText: LiveData = requiredTextEvent + val currentPage: LiveData = createPageItemLiveData( + questionListItemFactory = DefaultSurveyQuestionListItemFactory() + ) + + private val advanceButtonTextEvent = LiveEvent() + val advanceButtonText: LiveData = advanceButtonTextEvent + + private val progressBarNumberEvent = LiveEvent() + val progressBarNumber: LiveData = progressBarNumberEvent private val exitEvent = LiveEvent() val exitStream: LiveData = exitEvent @@ -74,9 +93,12 @@ class SurveyViewModel( private var submitAttempted: Boolean = false private var anyQuestionWasAnswered: Boolean = false + private var surveySubmitted: Boolean = false val title = model.name val termsAndConditions = model.termsAndConditionsLinkText + val isPaged = model.renderAs == RenderAs.PAGED + val pageCount = model.questionSet.size val surveyCancelConfirmationDisplay = with(model) { SurveyCancelConfirmationDisplay( @@ -86,20 +108,29 @@ class SurveyViewModel( closeConfirmCloseText ) } - //region Answers - fun updateAnswer(id: String, value: String) { - updateModel { - model.updateAnswer(id, SingleLineQuestion.Answer(value)) - updateQuestionAnsweredFlag(model.hasAnyAnswer) + fun updateAnswer(questionId: String, value: String) { + val oldAnswer = (model.getQuestion(questionId) as SingleLineQuestion).answer + val newAnswer = SingleLineQuestion.Answer(value) + + if (oldAnswer != newAnswer) { + updateModel { + model.updateAnswer(questionId, SingleLineQuestion.Answer(value)) + updateQuestionAnsweredFlag(hasAnyAnswer) + } } } - fun updateAnswer(id: String, selectedIndex: Int) { - updateModel { - model.updateAnswer(id, RangeQuestion.Answer(selectedIndex)) - updateQuestionAnsweredFlag(model.hasAnyAnswer) + fun updateAnswer(questionId: String, selectedIndex: Int) { + val oldAnswer = (model.getQuestion(questionId) as RangeQuestion).answer + val newAnswer = RangeQuestion.Answer(selectedIndex) + + if (oldAnswer != newAnswer) { + updateModel { + model.updateAnswer(questionId, RangeQuestion.Answer(selectedIndex)) + updateQuestionAnsweredFlag(hasAnyAnswer) + } } } @@ -115,7 +146,7 @@ class SurveyViewModel( ) if (oldAnswer != newAnswer) { model.updateAnswer(questionId, newAnswer) - updateQuestionAnsweredFlag(model.hasAnyAnswer) + updateQuestionAnsweredFlag(hasAnyAnswer) } } } @@ -128,47 +159,112 @@ class SurveyViewModel( //endregion - fun submit() { + fun submitListSurvey() { submitAttempted = true - updateModel { - if (model.allRequiredAnswersAreValid) { - executors.state.execute { - onSubmit( - model.questions - .filter { it.hasValidAnswer } - .associate { it.id to it.answer } - ) + if (allRequiredAnswersAreValid) { + submitSurvey() + showSuccessMessage() + exit(showConfirmation = false, successfulSubmit = true) + } else { + updatePageErrors() + } + } + } - if (!model.successMessage.isNullOrBlank()) { - surveySubmitMessageState.postValue( - SurveySubmitMessageState( - model.successMessage, - true - ) - ) + fun advancePage() { + updateModel { + if (allRequiredAnswersAreValid) { + recordCurrentAnswer() + when { + model.currentPageID == model.successPageID -> { + exit(showConfirmation = false, successfulSubmit = true) + } + isLastQuestionInSurvey() -> { + submitSurvey() + showSuccessPage() + } + else -> { + shownQuestions.addAll(model.currentQuestions) + model.goToNextPage() } - - exit(showConfirmation = false, successfulSubmit = true) } } else { - // trigger error message - if (!model.validationError.isNullOrBlank()) { - surveySubmitMessageState.postValue( - SurveySubmitMessageState( - model.validationError, - false - ) - ) - } + updatePageErrors() + } + } + } - // get index of first invalid question (description puts header item before questions) - var firstInvalidQuestionIndex = model.getFirstInvalidRequiredQuestionIndex() - if (model.description != null) firstInvalidQuestionIndex++ + private fun recordCurrentAnswer() { + recordCurrentAnswer( + model.currentQuestions + .associate { it.id to SurveyAnswerState.Answered(it.answer) } + ) + } - // trigger scrolling to the first invalid question - firstInvalidQuestionIndexEvent.postValue(firstInvalidQuestionIndex) + private fun resetCurrentAnswer() { + resetCurrentAnswer( + model.getAllQuestionsInTheSurvey().associate { + it.id to SurveyAnswerState.Answered(it.answer) } + ) + } + + private fun isLastQuestionInSurvey() = model.nextQuestionSetId == END_OF_QUESTION_SET || model.nextQuestionSetId == model.successPageID + + private fun showSuccessPage() { + if (model.nextQuestionSetId == model.successPageID) { + model.goToNextPage() + } else { + exit(showConfirmation = false, successfulSubmit = true) + } + } + + private fun updatePageErrors() { + // trigger error message + model.validationError?.let { errorMessage -> + surveySubmitMessageState.postValue( + SurveySubmitMessageState( + errorMessage, + false + ) + ) + } + + // get index of first invalid question (description puts header item before questions) + var firstInvalidQuestionIndex = getFirstInvalidRequiredQuestionIndex() + if (model.surveyIntroduction != null && model.renderAs == RenderAs.LIST) firstInvalidQuestionIndex++ + + // trigger scrolling to the first invalid question + firstInvalidQuestionIndexEvent.postValue(firstInvalidQuestionIndex) + } + + private fun showSuccessMessage() { + model.getCurrentPage().successText?.let { successMessage -> + surveySubmitMessageState.postValue( + SurveySubmitMessageState( + successMessage, + true + ) + ) + } + } + + private fun submitSurvey() { + if (!surveySubmitted) { + shownQuestions.addAll(model.currentQuestions) + val answeredQuestions = getValidAnsweredQuestions(shownQuestions) + val emptyQuestions = shownQuestions.toSet() - answeredQuestions.toSet() + val skippedQuestions = model.getAllQuestionsInTheSurvey().filter { q -> shownQuestions.none { s -> q.id == s.id } } + onSubmit( + answeredQuestions + .associate { it.id to SurveyAnswerState.Answered(it.answer) } + + emptyQuestions + .associate { it.id to SurveyAnswerState.Empty } + + skippedQuestions + .associate { it.id to SurveyAnswerState.Skipped } + ) + surveySubmitted = true } } @@ -178,7 +274,9 @@ class SurveyViewModel( @MainThread fun exit(showConfirmation: Boolean, successfulSubmit: Boolean = false) { - if (showConfirmation) { + val isSuccessPage = currentPage.value is SurveySuccessPageItem + + if (showConfirmation && !isSuccessPage) { // When the consumer uses the X button or the back button, // try to show the confirmation dialog if user interacted with the survey if (submitAttempted || anyQuestionWasAnswered) { @@ -193,8 +291,11 @@ class SurveyViewModel( // we are already in the confirmation dialog, so no need to show confirmation again exitEvent.postValue(true) executors.state.execute { - if (successfulSubmit) onClose.invoke() - else onCancelPartial.invoke() + if (successfulSubmit || isSuccessPage) onClose.invoke() + else { + resetCurrentAnswer() + onCancelPartial.invoke() + } } } } @@ -223,16 +324,17 @@ class SurveyViewModel( ) } return mutableListOf().apply { + val currentPage = model.getCurrentPage() // header - if (!model.description.isNullOrEmpty()) { - add(SurveyHeaderListItem(model.description)) + if (currentPage.introductionText?.isNotEmpty() == true) { + add(SurveyHeaderListItem(currentPage.introductionText)) } // questions addAll(questionsListItems) // footer - add(SurveyFooterListItem(model.submitText, model.disclaimerText, messageState)) + add(SurveyFooterListItem(currentPage.advanceActionLabel, currentPage.disclaimerText, messageState)) } } @@ -243,7 +345,7 @@ class SurveyViewModel( // 1. user pressed submit button // 2. model provides a validation error // 3. at least one of the required questions is not answered - val messageState: SurveySubmitMessageState? = if (submitAttempted && model.validationError != null && !model.allRequiredAnswersAreValid) { + val messageState: SurveySubmitMessageState? = if (submitAttempted && model.validationError != null && !allRequiredAnswersAreValid) { SurveySubmitMessageState(model.validationError, false) } else { null @@ -264,6 +366,70 @@ class SurveyViewModel( } } } + + private fun createPageItemLiveData( + questionListItemFactory: SurveyQuestionListItemFactory + ): LiveData { + fun createPageItem( + questions: List>?, + messageState: SurveySubmitMessageState? + ): SurveyListItem { + val questionList = questions ?: emptyList() + val showInvalidQuestionsFlag = messageState != null && !messageState.isValid + val questionsListItems = questionList.map { question -> + questionListItemFactory.createListItem( + question, + showInvalidQuestionsFlag + ) + } + + val currentPage = model.getCurrentPage() + advanceButtonTextEvent.value = currentPage.advanceActionLabel.orEmpty() + progressBarNumberEvent.value = currentPage.pageIndicatorValue + + return when { + currentPage.successText.isNotNullOrEmpty() -> { + SurveySuccessPageItem( + currentPage.successText, + currentPage.disclaimerText.orEmpty() + ) + } + + currentPage.introductionText.isNotNullOrEmpty() || currentPage.disclaimerText.isNotNullOrEmpty() -> { + SurveyIntroductionPageItem( + currentPage.introductionText.orEmpty(), + currentPage.disclaimerText.orEmpty() + ) + } + + currentPage.questions.isNotNullOrEmpty() -> questionsListItems.first() + + else -> throw IllegalStateException("Survey page is not valid") + } + } + + return MediatorLiveData().apply { + addSource(questionsStream) { questions -> + value = createPageItem(questions, null) + } + + addSource(surveySubmitMessageState) { messageState -> + value = createPageItem( + questionsStream.value, + messageState + ) + } + } + } + + /** Returns the index of the first REQUIRED invalid question/NOT REQUIRED but cannot submit + * (or -1 if all required questions are valid) */ + internal fun getFirstInvalidRequiredQuestionIndex(): Int { + return model.currentQuestions.indexOfFirst { + (it.isRequired && !it.hasValidAnswer) || + !it.canSubmitOptionalQuestion + } + } } internal data class SurveySubmitMessageState( @@ -278,7 +444,9 @@ data class SurveyCancelConfirmationDisplay( val negativeButtonMessage: String? ) -internal typealias SurveySubmitCallback = (Map) -> Unit +internal typealias SurveySubmitCallback = (Map) -> Unit +internal typealias RecordCurrentAnswerCallback = (Map) -> Unit +internal typealias ResetCurrentAnswerCallback = (Map) -> Unit internal typealias SurveyCancelCallback = () -> Unit internal typealias SurveyCancelPartialCallback = () -> Unit internal typealias SurveyCloseCallback = () -> Unit diff --git a/apptentive-survey/src/main/res/layout/apptentive_activity_survey.xml b/apptentive-survey/src/main/res/layout/apptentive_activity_survey.xml index 06270bd5..ce23ef72 100644 --- a/apptentive-survey/src/main/res/layout/apptentive_activity_survey.xml +++ b/apptentive-survey/src/main/res/layout/apptentive_activity_survey.xml @@ -50,14 +50,52 @@ + + + + + + + + + + + + diff --git a/apptentive-survey/src/main/res/layout/apptentive_survey_footer.xml b/apptentive-survey/src/main/res/layout/apptentive_survey_footer.xml index 6567b162..c9aa84e3 100644 --- a/apptentive-survey/src/main/res/layout/apptentive_survey_footer.xml +++ b/apptentive-survey/src/main/res/layout/apptentive_survey_footer.xml @@ -4,6 +4,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" + android:importantForAccessibility="no" android:orientation="vertical"> \ No newline at end of file diff --git a/apptentive-survey/src/main/res/layout/apptentive_survey_introduction.xml b/apptentive-survey/src/main/res/layout/apptentive_survey_introduction.xml new file mode 100644 index 00000000..aa779521 --- /dev/null +++ b/apptentive-survey/src/main/res/layout/apptentive_survey_introduction.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + diff --git a/apptentive-survey/src/main/res/layout/apptentive_survey_question_container.xml b/apptentive-survey/src/main/res/layout/apptentive_survey_question_container.xml index 7ed4d7e9..aaa91be0 100644 --- a/apptentive-survey/src/main/res/layout/apptentive_survey_question_container.xml +++ b/apptentive-survey/src/main/res/layout/apptentive_survey_question_container.xml @@ -1,7 +1,8 @@ - @@ -9,7 +10,7 @@ android:id="@+id/apptentive_question_layout" style="?apptentiveSurveyQuestionLayoutStyle" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="wrap_content"> - \ No newline at end of file + \ No newline at end of file diff --git a/apptentive-survey/src/main/res/layout/apptentive_survey_segmented_progress_bar.xml b/apptentive-survey/src/main/res/layout/apptentive_survey_segmented_progress_bar.xml new file mode 100644 index 00000000..62011156 --- /dev/null +++ b/apptentive-survey/src/main/res/layout/apptentive_survey_segmented_progress_bar.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/apptentive-survey/src/main/res/layout/apptentive_survey_segmented_progress_bar_item.xml b/apptentive-survey/src/main/res/layout/apptentive_survey_segmented_progress_bar_item.xml new file mode 100644 index 00000000..1403fb28 --- /dev/null +++ b/apptentive-survey/src/main/res/layout/apptentive_survey_segmented_progress_bar_item.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/apptentive-survey/src/main/res/layout/apptentive_survey_success_page.xml b/apptentive-survey/src/main/res/layout/apptentive_survey_success_page.xml new file mode 100644 index 00000000..7c38cb2d --- /dev/null +++ b/apptentive-survey/src/main/res/layout/apptentive_survey_success_page.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apptentive-survey/src/main/res/values/strings.xml b/apptentive-survey/src/main/res/values/strings.xml index 6023a57c..2050a95e 100644 --- a/apptentive-survey/src/main/res/values/strings.xml +++ b/apptentive-survey/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ You will lose your progress if you close this survey. Close survey? Required + Submit Lowest Highest diff --git a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/SurveyInteractionConverterTest.kt b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/SurveyInteractionConverterTest.kt index 3a2f9e97..bae42d9c 100644 --- a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/SurveyInteractionConverterTest.kt +++ b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/SurveyInteractionConverterTest.kt @@ -4,9 +4,38 @@ import apptentive.com.android.TestCase import apptentive.com.android.feedback.engagement.interactions.InteractionData import apptentive.com.android.serialization.json.JsonConverter import org.junit.Assert.assertEquals +import org.junit.Before import org.junit.Test class SurveyInteractionConverterTest : TestCase() { + private lateinit var surveyInteraction: SurveyInteraction + @Before + fun testSetup() { + surveyInteraction = SurveyInteraction( + id = "1", + name = "Survey 1", + description = "This is a survey", + renderAs = "default", + introButtonText = "Start", + nextText = "Next", + questionSet = listOf(), + isRequired = true, + requiredText = "This field is required", + validationError = "Invalid input", + showSuccessMessage = true, + successMessage = "Survey completed successfully", + successButtonText = "Finish", + closeConfirmTitle = "Confirm", + closeConfirmMessage = "Are you sure you want to close?", + closeConfirmCloseText = "Close", + closeConfirmBackText = "Go Back", + termsAndConditions = SurveyInteraction.TermsAndConditions( + "Terms", + "https://example.com/terms" + ), + disclaimerText = "Disclaimer" + ) + } @Test fun `SurveyInteractionTypeConverter sets null default value for the missing json attributes`() { val jsonString = """ @@ -23,7 +52,8 @@ class SurveyInteractionConverterTest : TestCase() { "show_success_message":true, "success_message":"success_message", "required":true, - "questions":[], + "render_as":"paged", + "question_sets":[], "terms_and_conditions": { "label": "labelTest", "link": "linkTest" @@ -37,7 +67,6 @@ class SurveyInteractionConverterTest : TestCase() { id = "1234567890", name = "name", description = "description", - submitText = "submit", requiredText = "required_text", validationError = "validation_error", showSuccessMessage = true, @@ -47,9 +76,13 @@ class SurveyInteractionConverterTest : TestCase() { closeConfirmCloseText = null, closeConfirmBackText = null, isRequired = true, - questions = emptyList(), + questionSet = emptyList(), termsAndConditions = SurveyInteraction.TermsAndConditions("labelTest", "linkTest"), - disclaimerText = "Disclaimer text" + disclaimerText = "Disclaimer text", + renderAs = "paged", + nextText = "NEXT", + introButtonText = "Intro", + successButtonText = "SUCCESS" ) val data = JsonConverter.fromJson(jsonString.trimIndent()) @@ -57,4 +90,52 @@ class SurveyInteractionConverterTest : TestCase() { assertEquals(expected.toString(), actual.toString()) } + + @Test + fun `compare json string with the SurveyInteraction`() { + val expected = "SurveyInteraction(id=1, name=\"Survey 1\", description=\"This is a survey\", " + + "requiredText=\"This field is required\", validationError=\"Invalid input\", " + + "showSuccessMessage=true, successMessage=\"Survey completed successfully\", " + + "closeConfirmTitle=\"Confirm\", closeConfirmMessage=\"Are you sure you want to close?\", " + + "closeConfirmCloseText=\"Close\", closeConfirmBackText=\"Go Back\", " + + "isRequired=true, questions=[], " + + "termsAndConditions=TermsAndConditions(label=Terms, link=https://example.com/terms), " + + "disclaimerText=Disclaimer)" + + val result = surveyInteraction.toString() + + assertEquals(expected, result) + } + + @Test + fun `check comparing different objects returns false`() { + val other = SurveyInteraction( + id = "2", + name = "Survey 2", + description = "This is another survey", + renderAs = "default", + introButtonText = "Start", + nextText = "Next", + questionSet = listOf(), + isRequired = true, + requiredText = "This field is required", + validationError = "Invalid input", + showSuccessMessage = true, + successMessage = "Survey completed successfully", + successButtonText = "Finish", + closeConfirmTitle = "Confirm", + closeConfirmMessage = "Are you sure you want to close?", + closeConfirmCloseText = "Close", + closeConfirmBackText = "Go Back", + termsAndConditions = SurveyInteraction.TermsAndConditions( + "Terms", + "https://example.com/terms" + ), + disclaimerText = "Disclaimer" + ) + + val result = surveyInteraction == other + + assertEquals(false, result) + } } diff --git a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/SurveyInteractionLauncherTest.kt b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/SurveyInteractionLauncherTest.kt index e3f5ef9e..736e20a0 100644 --- a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/SurveyInteractionLauncherTest.kt +++ b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/SurveyInteractionLauncherTest.kt @@ -13,12 +13,12 @@ import apptentive.com.android.feedback.engagement.MockEngagementContext import apptentive.com.android.feedback.engagement.PayloadSenderCallback import apptentive.com.android.feedback.survey.SurveyModelFactory import apptentive.com.android.feedback.survey.SurveyModelFactoryProvider -import apptentive.com.android.feedback.survey.model.MultiChoiceQuestion +import apptentive.com.android.feedback.survey.model.RenderAs import apptentive.com.android.feedback.survey.model.SurveyModel -import apptentive.com.android.feedback.survey.model.SurveyQuestion -import apptentive.com.android.feedback.survey.model.createMultiChoiceQuestion -import apptentive.com.android.feedback.survey.model.createRangeQuestion -import apptentive.com.android.feedback.survey.model.createSingleLineQuestion +import apptentive.com.android.feedback.survey.model.SurveyQuestionSet +import apptentive.com.android.feedback.survey.model.createMultiChoiceQuestionForV12 +import apptentive.com.android.feedback.survey.model.createRangeQuestionForV12 +import apptentive.com.android.feedback.survey.model.createSingleLineQuestionForV12 import apptentive.com.android.feedback.survey.utils.createSurveyViewModel import apptentive.com.android.toProperJson import io.mockk.every @@ -38,34 +38,16 @@ class SurveyInteractionLauncherTest : TestCase() { fun testViewModel() { val context = createEngagementContext() val model = createSurveyModel( - listOf( - createSingleLineQuestion(id = "id_1", answer = "text"), - createRangeQuestion(id = "id_2", selectedIndex = 5), - createMultiChoiceQuestion( - id = "id_3", - answerChoiceConfigs = listOf( - MultiChoiceQuestion.AnswerChoiceConfiguration( - id = "choice_1", - type = MultiChoiceQuestion.ChoiceType.select_option, - title = "Title 1" - ), - MultiChoiceQuestion.AnswerChoiceConfiguration( - id = "choice_2", - type = MultiChoiceQuestion.ChoiceType.select_other, - title = "Title 2" - ) - ), - answer = listOf( - MultiChoiceQuestion.Answer.Choice(id = "choice_1", checked = true), - MultiChoiceQuestion.Answer.Choice( - id = "choice_2", - checked = true, - value = "Other" - ) - ), - minSelections = 1, - maxSelections = 2 - ) + createSingleLineQuestionForV12(id = "id_1"), + createRangeQuestionForV12(id = "id_2"), + createMultiChoiceQuestionForV12( + id = "id_3", + answerChoiceConfigs = listOf( + mapOf("id" to "choice_1", "value" to "value", "type" to "select_option", "title" to "Title 1"), + mapOf("id" to "choice_2", "type" to "select_other", "title" to "Title 2", "value" to "value") + ), + minSelections = 1, + maxSelections = 2 ) ) @@ -82,12 +64,12 @@ class SurveyInteractionLauncherTest : TestCase() { val viewModel = createSurveyViewModel(context) - viewModel.submit() + viewModel.submitListSurvey() assertResults( // survey response payload toProperJson( - "{'response':{'id':'interaction_id','answers':{'id_1':[{'value':'text'}],'id_2':[{'value':5}],'id_3':[{'id':'choice_1'},{'id':'choice_2','value':'Other'}]},'session_id':'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx','client_created_at':1000.0,'client_created_at_utc_offset':-18000,'nonce':'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'}}" + "{'response':{'id':'interaction_id','answers':{'id_1':{'state':'empty'},'id_2':{'state':'empty'},'id_3':{'state':'empty'}},'session_id':'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx','client_created_at':1000.0,'client_created_at_utc_offset':-18000,'nonce':'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'}}" ), // "submit" event @@ -117,12 +99,12 @@ class SurveyInteractionLauncherTest : TestCase() { } ) - private fun createSurveyModel(questions: List>? = null): SurveyModel { + private fun createSurveyModel(vararg questionSet: SurveyQuestionSet): SurveyModel { return SurveyModel( interactionId = "interaction_id", - questions = questions ?: emptyList(), + questionSet = questionSet.toList(), name = "name", - description = "description", + surveyIntroduction = "description", submitText = "submitText", requiredText = "requiredText", validationError = null, @@ -133,7 +115,10 @@ class SurveyInteractionLauncherTest : TestCase() { closeConfirmCloseText = "close", closeConfirmBackText = "Back to survey", termsAndConditionsLinkText = SpannedString("Terms & Conditions"), - disclaimerText = "Disclaimer text" + disclaimerText = "Disclaimer text", + introButtonText = "START", + renderAs = RenderAs.LIST, + successButtonText = "THANK YOU" ) } } diff --git a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/SurveyQuestionSetTest.kt b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/SurveyQuestionSetTest.kt new file mode 100644 index 00000000..7580cb81 --- /dev/null +++ b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/SurveyQuestionSetTest.kt @@ -0,0 +1,60 @@ +package apptentive.com.android.feedback.survey.interaction + +import apptentive.com.android.feedback.model.InvocationData +import apptentive.com.android.feedback.survey.model.SurveyQuestionSet +import junit.framework.TestCase +import org.junit.Before +import org.junit.Test + +class SurveyQuestionSetTest : TestCase() { + + private lateinit var surveyQuestionSet: SurveyQuestionSet + + @Before + fun testSetup() { + surveyQuestionSet = SurveyQuestionSet( + id = "1", + invokes = listOf( + InvocationData( + interactionId = "2", + criteria = mapOf("key" to "value") + ) + ), + questions = listOf(), + buttonText = "Next", + shouldContinue = true, + ) + } + + @Test + fun getId_returnsId() { + val expected = "1" + + val result = surveyQuestionSet.id + + assertEquals(expected, result) + } + + @Test + fun getInvokes_returnsInvokes() { + val expected = listOf( + InvocationData( + interactionId = "2", + criteria = mapOf("key" to "value") + ) + ) + + val result = surveyQuestionSet.invokes + + assertEquals(expected, result) + } + + @Test + fun getButtonText_returnsButtonText() { + val expected = "Next" + + val result = surveyQuestionSet.buttonText + + assertEquals(expected, result) + } +} diff --git a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/TermsAndConditionsTest.kt b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/TermsAndConditionsTest.kt new file mode 100644 index 00000000..2356667c --- /dev/null +++ b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/interaction/TermsAndConditionsTest.kt @@ -0,0 +1,35 @@ +package apptentive.com.android.feedback.survey.interaction + +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class TermsAndConditionsTest { + private lateinit var termsAndConditions: SurveyInteraction.TermsAndConditions + + @Before + fun setUp() { + termsAndConditions = SurveyInteraction.TermsAndConditions( + label = "Terms", + link = "https://example.com/terms" + ) + } + + @Test + fun getLabel_returnsLabel() { + val expected = "Terms" + + val result = termsAndConditions.label + + Assert.assertEquals(expected, result) + } + + @Test + fun getLink_returnsLink() { + val expected = "https://example.com/terms" + + val result = termsAndConditions.link + + Assert.assertEquals(expected, result) + } +} diff --git a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/SingleLineQuestionTest.kt b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/SingleLineQuestionTest.kt index 44e8cb5f..9a78d7cc 100644 --- a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/SingleLineQuestionTest.kt +++ b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/SingleLineQuestionTest.kt @@ -14,7 +14,7 @@ class SingleLineQuestionTest { @Test fun testRequiredNoAnswer() { val question = createSingleLineQuestion(required = true) - assertThat(question.answerString).isNull() + assertThat(question.answerString).isEmpty() assertThat(question.hasValidAnswer).isFalse() } @@ -30,7 +30,7 @@ class SingleLineQuestionTest { val question = createSingleLineQuestion(required = true) // empty by default - assertThat(question.answerString).isNull() + assertThat(question.answerString).isEmpty() assertThat(question.hasValidAnswer).isFalse() // set a valid answer diff --git a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/SurveyModelTest.kt b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/SurveyModelTest.kt index d68ce79d..94969f9e 100644 --- a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/SurveyModelTest.kt +++ b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/SurveyModelTest.kt @@ -2,8 +2,10 @@ package apptentive.com.android.feedback.survey.model import android.text.SpannedString import apptentive.com.android.TestCase -import apptentive.com.android.feedback.survey.model.MultiChoiceQuestion.ChoiceType -import com.google.common.truth.Truth.assertThat +import apptentive.com.android.core.DependencyProvider +import apptentive.com.android.feedback.EngagementResult +import apptentive.com.android.feedback.engagement.MockEngagementContext +import apptentive.com.android.feedback.engagement.MockEngagementContextFactory import org.junit.Test class SurveyModelTest : TestCase() { @@ -12,11 +14,11 @@ class SurveyModelTest : TestCase() { @Test fun testQuestionStream() { val model = createSurveyModel( - createSingleLineQuestion(id = "id_1"), - createSingleLineQuestion(id = "id_2"), - createSingleLineQuestion(id = "id_3") + createSingleLineQuestionForV12(id = "id_1"), + createSingleLineQuestionForV12(id = "id_2"), + createSingleLineQuestionForV12(id = "id_3") ) - model.questionsStream.observe { + model.questionListSubject.observe { it.forEach { question -> addResult(question) } @@ -45,175 +47,71 @@ class SurveyModelTest : TestCase() { //endregion - //region All Valid Answers - @Test - fun testAllValidNonRequiredAnswers() { - // none of the questions contains a valid answer but none is also required so it's fine - val model = createSurveyModel( - createSingleLineQuestion(), - createRangeQuestion(), - createMultiChoiceQuestion( - answerChoiceConfigs = listOf( - MultiChoiceQuestion.AnswerChoiceConfiguration(ChoiceType.select_option, "choice_id", "value") + fun testIntroAndSuccessPage() { + val surveyModel = createSurveyModel(showSuccessMessage = false, renderAs = RenderAs.PAGED) + DependencyProvider.register( + MockEngagementContextFactory + { + MockEngagementContext( + onEngage = { args -> + addResult(args) + EngagementResult.InteractionNotShown("No runnable interactions") + }, + onSendPayload = {} ) - ) - ) - assertThat(model.allRequiredAnswersAreValid).isTrue() - } - - @Test - fun testAllValidRequiredAnswers() { - // all of the questions contains a valid answer and all are required - val model = createSurveyModel( - createSingleLineQuestion(answer = "text", required = true), - createRangeQuestion(selectedIndex = 5, required = true), - createMultiChoiceQuestion( - answerChoiceConfigs = listOf( - MultiChoiceQuestion.AnswerChoiceConfiguration(ChoiceType.select_option, "choice_id", "value") - ), - answer = listOf( - MultiChoiceQuestion.Answer.Choice("choice_id", checked = true) - ), - required = true - ) - ) - assertThat(model.allRequiredAnswersAreValid).isTrue() - } - - @Test - fun testSomeInvalidRequiredAnswers() { - // some of the questions contains a valid answer and all are required - val model = createSurveyModel( - createSingleLineQuestion(answer = "text", required = true), - createRangeQuestion(selectedIndex = 5, required = true), - createMultiChoiceQuestion( - answerChoiceConfigs = listOf( - MultiChoiceQuestion.AnswerChoiceConfiguration(ChoiceType.select_option, "choice_id", "value") - ), - required = true - ) - ) - assertThat(model.allRequiredAnswersAreValid).isFalse() - } - - @Test - fun testUpdatingAnswer() { - val questionId = "id" - val model = createSurveyModel( - createSingleLineQuestion(id = questionId, required = true) - ) - - // a single required question contains no answer - assertThat(model.allRequiredAnswersAreValid).isFalse() - - // update the answer - model.updateAnswer( - questionId = questionId, - answer = SingleLineQuestion.Answer("New Answer") - ) - - // all answers become valid - assertThat(model.allRequiredAnswersAreValid).isTrue() - - // remove the answer - model.updateAnswer( - questionId = questionId, - answer = SingleLineQuestion.Answer("") - ) - - // all answers become invalid - assertThat(model.allRequiredAnswersAreValid).isFalse() - - // update the answer - model.updateAnswer( - questionId = questionId, - answer = SingleLineQuestion.Answer("Another Answer") + } ) - // all answers become valid - assertThat(model.allRequiredAnswersAreValid).isTrue() + assert(surveyModel.currentPageID == surveyModel.introPageID) + assert(surveyModel.getNextQuestionSet() == null) } - //endregion - - //region First Invalid Index - @Test - fun testFirstInvalidQuestion() { - val model = createSurveyModel( - createSingleLineQuestion(id = "id_1", required = true), - createRangeQuestion(id = "id_2", required = true), - createMultiChoiceQuestion( - id = "id_3", - required = true, - answerChoiceConfigs = listOf( - MultiChoiceQuestion.AnswerChoiceConfiguration(ChoiceType.select_option, "choice_id", "value") - ) - ) - ) - - // all questions are invalid - assertThat(model.getFirstInvalidRequiredQuestionIndex()).isEqualTo(0) - - // answer the first question - model.updateAnswer( - questionId = "id_1", - answer = SingleLineQuestion.Answer("text") - ) - - // second question becomes first invalid - assertThat(model.getFirstInvalidRequiredQuestionIndex()).isEqualTo(1) - - // answer the third question - model.updateAnswer( - questionId = "id_3", - answer = MultiChoiceQuestion.Answer( - listOf( - MultiChoiceQuestion.Answer.Choice("choice_id", checked = true) + fun testWithOutSuccessPage() { + val surveyModel = createSurveyModel(renderAs = RenderAs.PAGED) + DependencyProvider.register( + MockEngagementContextFactory + { + MockEngagementContext( + onEngage = { args -> + addResult(args) + EngagementResult.InteractionNotShown("No runnable interactions") + }, + onSendPayload = {} ) - ) - ) - - // second question still invalid - assertThat(model.getFirstInvalidRequiredQuestionIndex()).isEqualTo(1) - - // answer the second question - model.updateAnswer( - questionId = "id_2", - answer = RangeQuestion.Answer(5) - ) - - // all questions are valid now - assertThat(model.getFirstInvalidRequiredQuestionIndex()).isEqualTo(-1) - - // remove the answer from the first question - model.updateAnswer( - questionId = "id_1", - answer = SingleLineQuestion.Answer("") + } ) - // first question becomes first invalid - assertThat(model.getFirstInvalidRequiredQuestionIndex()).isEqualTo(0) + assert(surveyModel.currentPageID == surveyModel.introPageID) + assert(surveyModel.getNextQuestionSet() == surveyModel.successPageID) + surveyModel.goToNextPage() + assert(surveyModel.currentPageID == surveyModel.successPageID) } - - //endregion - - private fun createSurveyModel(vararg questions: SurveyQuestion<*>) = SurveyModel( - interactionId = "interaction_id", - questions = questions.toList(), - name = "name", - description = "description", - submitText = "submitText", - requiredText = "requiredText", - validationError = "Validation error", - showSuccessMessage = false, - successMessage = "successMessage", - closeConfirmTitle = "Close survey?", - closeConfirmMessage = "All the changes will be lost", - closeConfirmCloseText = "close", - closeConfirmBackText = "Back to survey", - termsAndConditionsLinkText = SpannedString("Terms & Conditions"), - disclaimerText = "Disclaimer text" - ) } + +internal fun createSurveyModel( + vararg questionSet: SurveyQuestionSet, + renderAs: RenderAs = RenderAs.LIST, + showSuccessMessage: Boolean = true, + surveyIntroduction: String? = "description" +) = SurveyModel( + interactionId = "interaction_id", + questionSet = questionSet.toList(), + name = "name", + surveyIntroduction = surveyIntroduction, + submitText = "submitText", + requiredText = "requiredText", + validationError = "Validation error", + showSuccessMessage = showSuccessMessage, + successMessage = "successMessage", + closeConfirmTitle = "Close survey?", + closeConfirmMessage = "All the changes will be lost", + closeConfirmCloseText = "close", + closeConfirmBackText = "Back to survey", + termsAndConditionsLinkText = SpannedString("Terms & Conditions"), + disclaimerText = "Disclaimer text", + introButtonText = "START", + successButtonText = "THANK YOU", + renderAs = renderAs +) diff --git a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/SurveyResponsePayloadTest.kt b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/SurveyResponsePayloadTest.kt index f6f8d8f4..37e56722 100644 --- a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/SurveyResponsePayloadTest.kt +++ b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/SurveyResponsePayloadTest.kt @@ -22,13 +22,15 @@ class SurveyResponsePayloadTest : TestCase() { val payload = SurveyResponsePayload.fromAnswers( id = surveyId, answers = mapOf( - "1" to SingleLineQuestion.Answer(value = "answer"), - "2" to RangeQuestion.Answer(selectedIndex = 5), - "3" to MultiChoiceQuestion.Answer( - choices = listOf( - MultiChoiceQuestion.Answer.Choice(id = "choice_1", checked = true), - MultiChoiceQuestion.Answer.Choice(id = "choice_2", checked = false), - MultiChoiceQuestion.Answer.Choice(id = "choice_3", value = "text", checked = true) + "1" to SurveyAnswerState.Answered(SingleLineQuestion.Answer(value = "answer")), + "2" to SurveyAnswerState.Answered(RangeQuestion.Answer(selectedIndex = 5)), + "3" to SurveyAnswerState.Answered( + MultiChoiceQuestion.Answer( + choices = listOf( + MultiChoiceQuestion.Answer.Choice(id = "choice_1", checked = true), + MultiChoiceQuestion.Answer.Choice(id = "choice_2", checked = false), + MultiChoiceQuestion.Answer.Choice(id = "choice_3", value = "text", checked = true) + ) ) ) ) @@ -39,9 +41,22 @@ class SurveyResponsePayloadTest : TestCase() { "'response':{" + "'id':'$surveyId'," + "'answers':{" + - "'1':[{'value':'answer'}]," + - "'2':[{'value':5}]," + - "'3':[{'id':'choice_1'},{'id':'choice_3','value':'text'}]" + + "'1':{" + + "'state':'answered'," + + "'value':[{" + + "'value':'answer'}]" + + "}," + + "'2':{" + + "'state':'answered'," + + "'value':[{" + + "'value':5}]" + + "}," + + "'3':{" + + "'state':'answered'," + + "'value':[{" + + "'id':'choice_1'}," + + "{'id':'choice_3','value':'text'}]" + + "}" + "}," + "'session_id':'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'," + "'client_created_at':1000.0," + diff --git a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/questions.kt b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/questions.kt index 7661d32c..53773b87 100644 --- a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/questions.kt +++ b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/model/questions.kt @@ -1,5 +1,7 @@ package apptentive.com.android.feedback.survey.model +import apptentive.com.android.feedback.model.InvocationData + internal fun createSingleLineQuestion( id: String? = null, title: String? = null, @@ -75,3 +77,80 @@ internal fun createMultiChoiceQuestion( answer = if (answer != null) MultiChoiceQuestion.Answer(answer) else null ) } + +internal fun createSingleLineQuestionForV12( + id: String? = null, + title: String? = null, + errorMessage: String? = null, + required: Boolean = false, + answer: String? = null, + questionSetID: String = "First", + invocation: List = emptyList() +) = SurveyQuestionSet( + id = questionSetID, invokes = invocation, + questions = listOf( + mapOf( + "id" to (id ?: "id"), "value" to (title ?: "title"), "type" to "singleline", "required" to required, "error_message" to (errorMessage ?: "error_message") + ) + ), + buttonText = "NEXT", + shouldContinue = true, +) + +internal fun createRangeQuestionForV12( + id: String? = null, + title: String? = null, + errorMessage: String? = null, + required: Boolean = false, + min: Int = 0, + max: Int = 10, + minLabel: String? = null, + maxLabel: String? = null, + selectedIndex: Int? = null +) = SurveyQuestionSet( + id = "First", invokes = emptyList(), + questions = listOf( + mapOf( + "id" to (id ?: "id"), + "value" to (title ?: "title"), + "type" to "range", + "required" to required, + "error_message" to (errorMessage ?: "error_message"), + "min" to min, + "max" to max, + "min_label" to minLabel, + "max_label" to maxLabel, + "selected_index" to selectedIndex + ) + ), + buttonText = "NEXT", + shouldContinue = true, +) + +internal fun createMultiChoiceQuestionForV12( + id: String? = null, + title: String? = null, + errorMessage: String? = null, + required: Boolean = false, + allowMultipleAnswers: Boolean = false, + minSelections: Int = 1, + maxSelections: Int = 1, + answerChoiceConfigs: List>? = null, +) = SurveyQuestionSet( + id = "First", invokes = emptyList(), + questions = listOf( + mapOf( + "id" to (id ?: "id"), + "value" to (title ?: "title"), + "type" to "multichoice", + "required" to required, + "error_message" to (errorMessage ?: "error_message"), + "min_selections" to minSelections, + "max_selections" to maxSelections, + "multiselect" to allowMultipleAnswers, + "answer_choices" to answerChoiceConfigs + ) + ), + buttonText = "NEXT", + shouldContinue = true, +) diff --git a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/viewmodel/DefaultSurveyQuestionListItemFactoryTest.kt b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/viewmodel/DefaultSurveyQuestionListItemFactoryTest.kt index 9624212b..b5df9cce 100644 --- a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/viewmodel/DefaultSurveyQuestionListItemFactoryTest.kt +++ b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/viewmodel/DefaultSurveyQuestionListItemFactoryTest.kt @@ -34,7 +34,7 @@ class DefaultSurveyQuestionListItemFactoryTest { @Test fun testSingleLineInvalidRequiredQuestionAndPressSubmitButton() = testSingleLineQuestion( required = true, - answer = null, // invalid answer + answer = "", // invalid answer instructionsText = "Provide your input", pressedSubmitButton = true, expectedInstructions = "Required. Provide your input", @@ -44,7 +44,7 @@ class DefaultSurveyQuestionListItemFactoryTest { @Test fun testSingleLineInvalidRequiredQuestionAndDontPressSubmitButton() = testSingleLineQuestion( required = true, - answer = null, // invalid answer + answer = "", // invalid answer instructionsText = "Provide your input", pressedSubmitButton = false, expectedInstructions = "Required. Provide your input", @@ -54,7 +54,7 @@ class DefaultSurveyQuestionListItemFactoryTest { @Test fun testSingleLineInvalidNonRequiredQuestionAndPressSubmitButton() = testSingleLineQuestion( required = false, - answer = null, // invalid answer + answer = "", // invalid answer instructionsText = "Provide your input", pressedSubmitButton = true, expectedInstructions = "Provide your input", @@ -73,7 +73,7 @@ class DefaultSurveyQuestionListItemFactoryTest { private fun testSingleLineQuestion( required: Boolean, - answer: String?, + answer: String, instructionsText: String?, pressedSubmitButton: Boolean, expectedInstructions: String?, diff --git a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/viewmodel/SurveyInteractionResponseTest.kt b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/viewmodel/SurveyInteractionResponseTest.kt new file mode 100644 index 00000000..474d7dbd --- /dev/null +++ b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/viewmodel/SurveyInteractionResponseTest.kt @@ -0,0 +1,68 @@ +package apptentive.com.android.feedback.survey.viewmodel + +import apptentive.com.android.TestCase +import apptentive.com.android.feedback.engagement.interactions.InteractionResponse +import apptentive.com.android.feedback.survey.model.MultiChoiceQuestion +import apptentive.com.android.feedback.survey.model.RangeQuestion +import apptentive.com.android.feedback.survey.model.SingleLineQuestion +import apptentive.com.android.feedback.survey.model.SurveyAnswerState +import apptentive.com.android.feedback.survey.utils.mapAnswersToResponses +import org.junit.Assert.assertEquals +import org.junit.Test + +class SurveyInteractionResponseTest : TestCase() { + @Test + fun `test mapAnswersToResponses with MultiChoiceQuestion`() { + val questionId = "question1" + val answer = MultiChoiceQuestion.Answer(choices = listOf(MultiChoiceQuestion.Answer.Choice("choice1", checked = true))) + val answers = mapOf(questionId to SurveyAnswerState.Answered(answer)) + + val result = mapAnswersToResponses(answers) + + val expected = mapOf( + questionId to setOf(InteractionResponse.IdResponse("choice1")) + ) + assertEquals(expected, result) + } + + @Test + fun `test mapAnswersToResponses with SingleLineQuestion`() { + // String response with non-empty value + val questionId = "question2" + val answer = SingleLineQuestion.Answer(value = "Answer") + val answers = mapOf(questionId to SurveyAnswerState.Answered(answer)) + + var result = mapAnswersToResponses(answers) + + var expected = mapOf( + questionId to setOf(InteractionResponse.StringResponse("Answer")) + ) + assertEquals(expected, result) + + // String response with empty value + val questionId2 = "question2" + val answer2 = SingleLineQuestion.Answer(value = "") + val answers2 = mapOf(questionId2 to SurveyAnswerState.Answered(answer2)) + + result = mapAnswersToResponses(answers2) + + expected = mapOf( + questionId2 to emptySet() + ) + assertEquals(expected, result) + } + + @Test + fun `test mapAnswersToResponses with RangeQuestion`() { + val questionId = "question3" + val answer = RangeQuestion.Answer(selectedIndex = 5) + val answers = mapOf(questionId to SurveyAnswerState.Answered(answer)) + + val result = mapAnswersToResponses(answers) + + val expected = mapOf( + questionId to setOf(InteractionResponse.LongResponse(5L)) + ) + assertEquals(expected, result) + } +} diff --git a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/viewmodel/SurveyViewModelTest.kt b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/viewmodel/SurveyViewModelTest.kt index 6b104e4d..f6ca1531 100644 --- a/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/viewmodel/SurveyViewModelTest.kt +++ b/apptentive-survey/src/test/java/apptentive/com/android/feedback/survey/viewmodel/SurveyViewModelTest.kt @@ -4,11 +4,21 @@ import android.text.SpannedString import androidx.arch.core.executor.testing.InstantTaskExecutorRule import apptentive.com.android.TestCase import apptentive.com.android.concurrent.mockExecutors +import apptentive.com.android.feedback.survey.model.MultiChoiceQuestion +import apptentive.com.android.feedback.survey.model.RangeQuestion +import apptentive.com.android.feedback.survey.model.RenderAs import apptentive.com.android.feedback.survey.model.SingleLineQuestion +import apptentive.com.android.feedback.survey.model.SurveyAnswerState import apptentive.com.android.feedback.survey.model.SurveyModel -import apptentive.com.android.feedback.survey.model.SurveyQuestion -import apptentive.com.android.feedback.survey.model.createSingleLineQuestion +import apptentive.com.android.feedback.survey.model.SurveyQuestionSet +import apptentive.com.android.feedback.survey.model.createMultiChoiceQuestionForV12 +import apptentive.com.android.feedback.survey.model.createRangeQuestionForV12 +import apptentive.com.android.feedback.survey.model.createSingleLineQuestionForV12 +import apptentive.com.android.feedback.survey.model.createSurveyModel +import apptentive.com.android.feedback.survey.utils.getValidAnsweredQuestions +import com.google.common.truth.Truth.assertThat import org.junit.Assert.assertEquals +import org.junit.Ignore import org.junit.Rule import org.junit.Test @@ -53,7 +63,7 @@ class SurveyViewModelTest : TestCase() { ) // attempt to submit the survey - viewModel.submit() + viewModel.submitListSurvey() // check results assertResults( @@ -100,13 +110,14 @@ class SurveyViewModelTest : TestCase() { ) // submit the survey - viewModel.submit() + viewModel.submitListSurvey() // check results: unanswered question should be omitted assertResults( "submit", mapOf( - questionId1 to SingleLineQuestion.Answer("Answer") + questionId1 to SurveyAnswerState.Answered(SingleLineQuestion.Answer("Answer")), + questionId2 to SurveyAnswerState.Empty ), SurveyHeaderListItem(instructions = surveyDescription), SingleLineQuestionListItem( @@ -128,6 +139,12 @@ class SurveyViewModelTest : TestCase() { ) } + @Test + fun testPagedSurvey() { + val viewModel = createViewModel(renderAs = RenderAs.PAGED) + viewModel.advancePage() + } + @Test fun testNoSurveyDescription() { // header list item should be missing @@ -187,7 +204,7 @@ class SurveyViewModelTest : TestCase() { ) // attempt to submit the survey - viewModel.submit() + viewModel.submitListSurvey() // check results: First question is invalid - has validation error assertResults( @@ -266,7 +283,7 @@ class SurveyViewModelTest : TestCase() { val viewModel = createViewModelForExitConfirmationTest() // attempt to submit the survey - viewModel.submit() + viewModel.submitListSurvey() // attempt to exit with confirmation viewModel.exit(showConfirmation = true) @@ -298,7 +315,7 @@ class SurveyViewModelTest : TestCase() { val viewModel = createViewModelForExitConfirmationTest() // try to submit - viewModel.submit() + viewModel.submitListSurvey() // answer the question viewModel.updateAnswer("id_1", "My answer") @@ -323,6 +340,226 @@ class SurveyViewModelTest : TestCase() { assertResults("back to survey") } + @Test + fun testAllValidNonRequiredAnswers() { + // none of the questions contains a valid answer but none is also required so it's fine + val model = createSurveyModel( + createSingleLineQuestionForV12(), + createRangeQuestionForV12(), + createMultiChoiceQuestionForV12( + answerChoiceConfigs = listOf(mapOf("id" to "choice_id", "value" to "value", "type" to "select_option")) + ) + ) + val viewModel = SurveyViewModel(model, executors = mockExecutors, {}, {}, {}, {}, {}, {}, {}) + assertThat(viewModel.allRequiredAnswersAreValid).isTrue() + } + + @Test + @Ignore("Need to figure out how to pass the answer to the question set or probably figure out a different way to test this") + fun testAllValidRequiredAnswers() { + // all of the questions contains a valid answer and all are required + val model = createSurveyModel( + createSingleLineQuestionForV12(required = true), + createRangeQuestionForV12(selectedIndex = 5, required = true), + createMultiChoiceQuestionForV12( + answerChoiceConfigs = listOf( + mapOf("id" to "choice_id", "value" to "value", "type" to "select_option") + ), + required = true + ) + ) + val viewModel = SurveyViewModel(model, executors = mockExecutors, {}, {}, {}, {}, {}, {}, {}) + assertThat(viewModel.allRequiredAnswersAreValid).isTrue() + } + + @Test + fun testSomeInvalidRequiredAnswers() { + // some of the questions contains a valid answer and all are required + val model = createSurveyModel( + createSingleLineQuestionForV12(required = true), + createRangeQuestionForV12(selectedIndex = 5, required = true), + createMultiChoiceQuestionForV12( + answerChoiceConfigs = listOf( + mapOf("id" to "choice_id", "value" to "value", "type" to "select_option") + ), + required = true + ) + ) + val viewModel = SurveyViewModel(model, executors = mockExecutors, {}, {}, {}, {}, {}, {}, {}) + assertThat(viewModel.allRequiredAnswersAreValid).isFalse() + } + + @Test + fun testUpdatingAnswer() { + val questionId = "id" + val model = createSurveyModel( + createSingleLineQuestionForV12(id = questionId, required = true) + ) + + val viewModel = SurveyViewModel(model, executors = mockExecutors, {}, {}, {}, {}, {}, {}, {}) + + // a single required question contains no answer + assertThat(viewModel.allRequiredAnswersAreValid).isFalse() + + // update the answer + viewModel.updateAnswer( + questionId = questionId, + value = "New Answer" + ) + + // all answers become valid + assertThat(viewModel.allRequiredAnswersAreValid).isTrue() + + // remove the answer + viewModel.updateAnswer( + questionId = questionId, + value = "" + ) + + // all answers become invalid + assertThat(viewModel.allRequiredAnswersAreValid).isFalse() + + // update the answer + viewModel.updateAnswer( + questionId = questionId, + value = "Another Answer" + ) + + // all answers become valid + assertThat(viewModel.allRequiredAnswersAreValid).isTrue() + } + + //endregion + + //region First Invalid Index + + @Test + fun testFirstInvalidQuestion() { + val model = createSurveyModel( + createSingleLineQuestionForV12(id = "id_1", required = true), + createRangeQuestionForV12(id = "id_2", required = true), + createMultiChoiceQuestionForV12( + id = "id_3", + required = true, + answerChoiceConfigs = listOf( + mapOf("id" to "choice_id", "value" to "value", "type" to "select_option") + ) + ) + ) + + val viewModel = SurveyViewModel(model, executors = mockExecutors, {}, {}, {}, {}, {}, {}, {}) + // all questions are invalid + assertThat(viewModel.getFirstInvalidRequiredQuestionIndex()).isEqualTo(0) + + // answer the first question + viewModel.updateAnswer( + questionId = "id_1", + value = "text" + ) + + // second question becomes first invalid + assertThat(viewModel.getFirstInvalidRequiredQuestionIndex()).isEqualTo(1) + + // answer the third question + viewModel.updateAnswer( + questionId = "id_3", + choiceId = "choice_id", + selected = true, + text = null + ) + + // second question still invalid + assertThat(viewModel.getFirstInvalidRequiredQuestionIndex()).isEqualTo(1) + + // answer the second question + viewModel.updateAnswer( + questionId = "id_2", + selectedIndex = 5 + ) + + // all questions are valid now + assertThat(viewModel.getFirstInvalidRequiredQuestionIndex()).isEqualTo(-1) + + // remove the answer from the first question + viewModel.updateAnswer( + questionId = "id_1", + value = "" + ) + + // first question becomes first invalid + assertThat(viewModel.getFirstInvalidRequiredQuestionIndex()).isEqualTo(0) + } + + @Test + fun testValidAnsweredQuestions() { + val question1 = SingleLineQuestion( + id = "1", + title = "Question 1", + validationError = "Invalid answer", + required = true, + requiredText = "This question is required", + instructionsText = "instructions" + ) + question1.answer = SingleLineQuestion.Answer(value = "Answer 1") // Answered and valid + + val question2 = RangeQuestion( + id = "2", + title = "Question 2", + validationError = "Invalid answer", + required = true, + requiredText = "This question is required", + instructionsText = "instructions", + min = 1, + max = 5 + ) + question2.answer = RangeQuestion.Answer(selectedIndex = null) // Empty - Null selectedIndex + + val question3 = MultiChoiceQuestion( + id = "3", + title = "Question 3", + validationError = "Invalid answer", + required = true, + requiredText = "This question is required", + answerChoiceConfigs = listOf( + MultiChoiceQuestion.AnswerChoiceConfiguration( + type = MultiChoiceQuestion.ChoiceType.select_option, + id = "a", + title = "Option A" + ), + MultiChoiceQuestion.AnswerChoiceConfiguration( + type = MultiChoiceQuestion.ChoiceType.select_option, + id = "b", + title = "Option B" + ) + ), + allowMultipleAnswers = true, + minSelections = 1, + maxSelections = 1 + ) + question3.answer = MultiChoiceQuestion.Answer( + choices = listOf( + MultiChoiceQuestion.Answer.Choice(id = "a", checked = true) + ) + ) + + val question4 = SingleLineQuestion( + id = "4", + title = "Question 4", + validationError = "Invalid answer", + required = true, + requiredText = "This question is required", + instructionsText = "instructions" + ) + question4.answer = SingleLineQuestion.Answer(value = "") // Empty answer value + + val shownQuestions = listOf(question1, question2, question3, question4) + + val validAnsweredQuestions = getValidAnsweredQuestions(shownQuestions) + + val expectedValidQuestions = listOf(question1, question3) + assertEquals(expectedValidQuestions, validAnsweredQuestions) + } + private fun createViewModelForExitConfirmationTest(): SurveyViewModel { val surveyValidationErrorState = SurveySubmitMessageState( message = "Survey is not valid", @@ -347,35 +584,43 @@ class SurveyViewModelTest : TestCase() { } private fun createViewModel( - questions: List> = listOf( - // required question - createSingleLineQuestion( - id = "id_1", - title = "title 1", - required = true, - requiredText = "Required", - errorMessage = "Question 1 is invalid" + questionSet: List = listOf( + SurveyQuestionSet( + id = "First", + invokes = emptyList(), + questions = listOf( + mapOf( + "id" to "id_1", "value" to "title 1", "type" to "singleline", "required" to true, "error_message" to "Question 1 is invalid" + ) + ), + buttonText = "NEXT", + shouldContinue = true, ), - // non-required question - createSingleLineQuestion( - id = "id_2", - title = "title 2", - required = false, - errorMessage = "Question 2 is invalid" + SurveyQuestionSet( + id = "Second", + invokes = emptyList(), + questions = listOf( + mapOf( + "id" to "id_2", "value" to "title 2", "type" to "singleline", "required" to false, "error_message" to "Question 2 is invalid" + ) + ), + buttonText = "NEXT", + shouldContinue = true, ) ), name: String? = "name", description: String? = "description", - submitText: String? = "submitText", - requiredText: String? = "requiredText", + submitText: String = "submitText", + requiredText: String = "Required", validationError: String? = "validationError", - successMessage: String? = "successMessage" + successMessage: String? = "successMessage", + renderAs: RenderAs = RenderAs.LIST ): SurveyViewModel { val model = SurveyModel( interactionId = "interaction_id", - questions = questions, + questionSet = questionSet, name = name, - description = description, + surveyIntroduction = description, submitText = submitText, requiredText = requiredText, validationError = validationError, @@ -386,7 +631,10 @@ class SurveyViewModelTest : TestCase() { closeConfirmCloseText = "close", closeConfirmBackText = "Back to survey", termsAndConditionsLinkText = SpannedString("Terms & Conditions"), - disclaimerText = "Disclaimer text" + disclaimerText = "Disclaimer text", + introButtonText = "INTRO", + successButtonText = "THANKS!", + renderAs = RenderAs.LIST ) return SurveyViewModel( model = model, @@ -395,6 +643,8 @@ class SurveyViewModelTest : TestCase() { addResult("submit") addResult(it) }, + recordCurrentAnswer = { + }, onCancel = { addResult("cancel") }, @@ -406,6 +656,8 @@ class SurveyViewModelTest : TestCase() { }, onBackToSurvey = { addResult("back to survey") + }, + resetCurrentAnswer = { } ) } diff --git a/build.gradle b/build.gradle index 0c3e7d49..77cc3332 100644 --- a/build.gradle +++ b/build.gradle @@ -68,8 +68,8 @@ buildscript { } project.ext { - sonatypeVersion = '6.0.5' - jfrogVersion = '6.0.4-public06' + sonatypeVersion = '6.1.0' + jfrogVersion = '6.1.0-private07' // Change this depending on where you are publishing to repoVersion = sonatypeVersion diff --git a/local-template.properties b/local-template.properties new file mode 100644 index 00000000..dca83c50 --- /dev/null +++ b/local-template.properties @@ -0,0 +1,15 @@ +## This file is a template and the local.properties should have all the original information. This is required for the build/release process" + +sdk.dir=/Users//Library/Android/sdk +artifactory_username= +artifactory_password= + +## Find additional info regarding signing configuration +# https://apptentive.atlassian.net/wiki/spaces/ENG/pages/2464186369/Apptentive+SDK+Builder#Prerequisites +signing.keyId= +signing.password= +signing.secretKeyRingFile= + +sonatypeUsername= +sonatypePassword= +sonatypeStagingProfileId=