From 5427c113f07c7d6e8525bd67a4ac93f719d4e77e Mon Sep 17 00:00:00 2001
From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com>
Date: Tue, 27 Jun 2023 19:04:24 -0600
Subject: [PATCH 01/33] Fix #5072 : Remove condition of hex color for drawables
from regex (#5076)
## Explanation
Fix https://github.com/oppia/oppia-android/issues/5072 : Remove
condition of hex color for drawables from regex
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---
.../file_content_validation_checks.textproto | 11 -------
.../regex/RegexPatternValidationCheckTest.kt | 32 +------------------
2 files changed, 1 insertion(+), 42 deletions(-)
diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto
index ab9c8af53ec..40a355c58f2 100644
--- a/scripts/assets/file_content_validation_checks.textproto
+++ b/scripts/assets/file_content_validation_checks.textproto
@@ -352,17 +352,6 @@ file_content_checks {
prohibited_content_regex: "@color/(?!component_color_).+|\"#\\p{XDigit}+\""
failure_message: "Only colors from component_colors.xml may be used in layouts."
}
-file_content_checks {
- file_path_regex: "app/src/main/res/drawable.*?/.+?\\.xml"
- prohibited_content_regex: "@color/(?!component_color_).+|\"#\\p{XDigit}+\""
- failure_message: "Only colors from component_colors.xml may be used in drawables except vector assets."
- exempted_file_patterns: "app/src/main/res/drawable.*?/(ic_|lesson_thumbnail_graphic_).+?\\.xml"
- exempted_file_patterns: "app/src/main/res/drawable/full_oppia_logo.xml"
- exempted_file_patterns: "app/src/main/res/drawable/rounded_white_background_with_shadow.xml"
- exempted_file_patterns: "app/src/main/res/drawable/profile_image_shadow.xml"
- exempted_file_patterns: "app/src/main/res/drawable/selected_region_background.xml"
- exempted_file_patterns: "app/src/main/res/drawable/splash_page.xml"
-}
file_content_checks {
file_path_regex: "app/src/main/java/org/oppia/android/app.?/.+(ActivityPresenter|FragmentPresenter|ViewPresenter|Activity|Fragment|View)\\.kt"
prohibited_content_regex: "R.color.(?!component_color_).+"
diff --git a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt
index 20fd219f857..ae1d4fa60ff 100644
--- a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt
+++ b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt
@@ -173,8 +173,6 @@ class RegexPatternValidationCheckTest {
"Only colors from color_defs.xml may be used in color_palette.xml."
private val doesNotReferenceColorFromComponentColorInLayouts =
"Only colors from component_colors.xml may be used in layouts."
- private val doesNotReferenceColorFromComponentColorInDrawables =
- "Only colors from component_colors.xml may be used in drawables except vector assets."
private val doesNotReferenceColorFromComponentColorInKotlinFiles =
"Only colors from component_colors.xml may be used in Kotlin Files (Activities, Fragments, " +
"Views and Presenters)."
@@ -2345,35 +2343,7 @@ class RegexPatternValidationCheckTest {
)
}
- @Test
- fun testFileContent_xmlDrawables_includesNonColorComponentReferences_fileContentIsNotCorrect() {
- val prohibitedContent =
- """
- android:color="@color/component_color_shared_primary_text_color"
- android:color="@color/color_defs_shared_primary_text_color"
- android:color="@color/color_palette_primary_text_color"
- android:color="#003933"
- """.trimIndent()
- tempFolder.newFolder("testfiles", "app", "src", "main", "res", "drawable")
- val stringFilePath = "app/src/main/res/drawable/test_layout.xml"
- tempFolder.newFile("testfiles/$stringFilePath").writeText(prohibitedContent)
-
- val exception = assertThrows(Exception::class) {
- runScript()
- }
-
- // Verify that all patterns are properly detected & prohibited.
- assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR)
- assertThat(outContent.toString().trim())
- .isEqualTo(
- """
- $stringFilePath:2: $doesNotReferenceColorFromComponentColorInDrawables
- $stringFilePath:3: $doesNotReferenceColorFromComponentColorInDrawables
- $stringFilePath:4: $doesNotReferenceColorFromComponentColorInDrawables
- $wikiReferenceNote
- """.trimIndent()
- )
- }
+ // TODO(#5075): Add test for drawables file that checks color uses only component colors
@Test
fun testFileContent_kotlinFiles_includesNonColorComponentReferences_fileContentIsNotCorrect() {
From d049a5a8ba2423802391475f355f99880b479c48 Mon Sep 17 00:00:00 2001
From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com>
Date: Fri, 30 Jun 2023 06:07:29 -0600
Subject: [PATCH 02/33] [Android Wiki] Fix Part of #2746 : Added Bazel
Installations instructions for different Operating System (#4926)
## Explanation
Fix Part of #2746 : Added Bazel Installations instructions for different
Operating System
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
---
wiki/Bazel-Setup-Instructions-for-Linux.md | 66 ++++++++++++
wiki/Bazel-Setup-Instructions-for-Mac.md | 103 +++++++++++++++++++
wiki/Bazel-Setup-Instructions-for-Windows.md | 16 +++
wiki/Oppia-Bazel-Setup-Instructions.md | 39 ++-----
4 files changed, 192 insertions(+), 32 deletions(-)
create mode 100644 wiki/Bazel-Setup-Instructions-for-Linux.md
create mode 100644 wiki/Bazel-Setup-Instructions-for-Mac.md
diff --git a/wiki/Bazel-Setup-Instructions-for-Linux.md b/wiki/Bazel-Setup-Instructions-for-Linux.md
new file mode 100644
index 00000000000..df69862fa88
--- /dev/null
+++ b/wiki/Bazel-Setup-Instructions-for-Linux.md
@@ -0,0 +1,66 @@
+## Instructions
+
+**The steps to install Bazel on Linux are:**
+1. Install Bazel
+2. Install OpenJDK 8
+3. Install Python 2 and make sure it is active in your environment
+4. Set up the ANDROID_HOME environment variable
+5. Prepare the build environment
+6. Verify the Android build
+
+### 1. Install Bazel
+
+Install Bazel from [here](https://docs.bazel.build/versions/master/install.html). Make sure that you follow the instructions for installing a specific version (Oppia Android requires 4.0.0 and won't build on other versions).
+ - Note: if you find any errors related to `cURL`, please set up cURL on your machine. For Linux, you can use `sudo apt install curl`.
+
+### 2. Install OpenJDK 8
+
+Oppia Android also requires OpenJDK 8. The Bazel installation instructions above include [sections on installing OpenJDK](https://docs.bazel.build/versions/main/tutorial/java.html#install-the-jdk) on different platforms.
+
+ - You can run the following to install OpenJDK 8:
+
+ ```sh
+ sudo apt install openjdk-8-jdk
+ ```
+
+ You can confirm that this is set up using the command `java -version`, which should result in three lines being printed out with the first one showing "openjdk version "1.8.0_292".
+
+### 3. Install Python 2
+
+Ensure that you have Python 2 installed and make sure that it is currently active on your environment. You can do this by using the ``python --version`` command which should show Python 2.X.X. If it doesn’t, click [here](https://linuxconfig.org/install-python-2-on-ubuntu-20-04-focal-fossa-linux) for a resource on how to install and update Linux to use Python 2.
+
+### 4. Set up the ANDROID_HOME environment variable
+
+Ensure that your `ANDROID_HOME` environment variable is set to the location of your Android SDK. To do this, find the path to the installed SDK using Android Studio’s SDK Manager (install SDK 28). Assuming the SDK is installed to default locations, you can use the following commands to set the `ANDROID_HOME` variable:
+ ```
+ export ANDROID_HOME=$HOME/Android/Sdk/
+ ```
+ - **Make sure you have the system environment variable set up** for ``ANDROID_HOME`` as you might have issues getting properly set up if not. If it isn’t set up (on Linux you can check by using ``echo $ANDROID_HOME`` in a new terminal; it should output the correct path to your Android SDK), on Linux you can move the ``export`` from above to your ``~/.bashrc`` file to make it permanent (you can apply the change immediately using ``source ~/.bashrc``).
+
+### 5. Prepare the build environment
+
+Follow the instructions in [oppia-bazel-tools](https://github.com/oppia/oppia-bazel-tools).
+
+### 6. Verifying the build
+
+At this point, your system should be able to build Oppia Android. To verify, try building the APK (from your subsystem terminal -- note that this and all other Bazel commands must be run from the root of the ‘oppia-android’ directory otherwise they will fail):
+
+```sh
+bazel build //:oppia
+```
+
+(Note that this command may take 10-20 minutes to complete depending on the performance of your machine).
+
+If everything is working, you should see output like the following:
+
+```
+Target //:oppia up-to-date:
+ bazel-bin/oppia_deploy.jar
+ bazel-bin/oppia_unsigned/apk
+ bazel-bin/oppia/apk
+INFO: Elapsed time: ...
+INFO: 1 process...
+INFO: Build completed successfully, ...
+```
+
+Note also that the ``oppia.apk`` under the ``bazel-bin`` directory of your local copy of Oppia Android should be a fully functioning development version of the app that can be installed using ``adb``
diff --git a/wiki/Bazel-Setup-Instructions-for-Mac.md b/wiki/Bazel-Setup-Instructions-for-Mac.md
new file mode 100644
index 00000000000..11fd0116f8b
--- /dev/null
+++ b/wiki/Bazel-Setup-Instructions-for-Mac.md
@@ -0,0 +1,103 @@
+## Bazel Set up for Mac including Apple silicon (M1/M2) chips
+
+## Instructions
+
+**The steps to install Bazel on Mac are:**
+1. Set up Rosetta Terminal
+2. Install Bazel
+3. Install OpenJDK 8
+4. Install Python 2 and make sure it is active in your environment
+5. Set Bazel, Python 2, ANDROID_HOME paths permanently in your terminal
+6. Prepare the build environment
+7. Verify that the build is working
+
+### 1. Set up Rosetta Terminal
+
+- In the Finder app on your Mac, locate the Applications folder from the favorites sidebar.
+- Right-click on your Terminal app and create a duplicate Terminal (and rename it accordingly, say **Terminal Rosetta**, to avoid confusion).
+- On the newly created Terminal Rosetta icon, right-click and select "Get info", and under “General”, check the option "Open using Rosetta".
+
+**Note: Always use the Rosetta terminal for Bazel setup and running `bash setup.sh` or any Bazel build-related commands.**
+
+### 2. Install Bazel
+
+1. Install Bazel following the instructions [here](https://docs.bazel.build/versions/4.0.0/install-os-x.html#install-with-installer-mac-os-x). Make sure that you follow the instructions for installing a specific version (Oppia Android requires 4.0.0 and won't build on other versions).
+
+2. That’s it, now Bazel is installed, and you can verify it by running the command:
+ ```
+ bazel --version
+ ```
+ - **Expected Output**
+ ```
+ bazel 4.0.0
+ ```
+
+### 3. Install OpenJDK 8
+
+Oppia Android also requires OpenJDK 8.
+Follow the instructions [here](https://installvirtual.com/install-openjdk-8-on-mac-using-brew-adoptopenjdk/) to install OpenJDK 8.
+Note that this requires the installation of brew as a pre-requisite, which can be done by following the instructions [here](https://mac.install.guide/homebrew/index.html). You can then set up your `$JAVA_HOME` environment variable using these [instructions](https://stackoverflow.com/a/75167958/11396524).
+
+
+### 4. Install Python 2 and make sure it is active in your environment
+
+To install Python 2 in MacOS follow the follows the commands given below. Note that this requires installation of brew as a pre-requisite, which can be done by following the instructions [here](https://mac.install.guide/homebrew/index.html).
+```
+brew install pyenv
+pyenv install 2.7.18
+pyenv global 2.7.18
+```
+
+- To make sure Python 2 is successfully installed and active in your environment, navigate to the **oppia-android** directory and run the following commands:
+
+```
+export PATH="$(pyenv root)/shims:${PATH}"
+python --version
+```
+
+### 5. Set Bazel, Python 2, ANDROID_HOME paths permanently in your terminal
+
+- To set the `Bazel`, `Python 2`, `ANDROID_HOME` path permanently in your terminal run these commands:
+ ```
+ sudo nano /etc/paths
+ ```
+ - Enter your password, when prompted.
+ - Go to the bottom of the file, and enter these paths
+ ```
+ /Users/{YourMacUserName}/bin
+ $(pyenv root)/shims:${PATH}
+ $HOME/Library/Android/sdk
+ ```
+ - Hit control-x to quit.
+ - Enter “Y” to save the modified buffer.
+ - That’s it! To test it, in a new terminal window, type: `echo $PATH`
+
+**Note: You must set the path for `Bazel`, `Python 2`, `ANDROID_HOME` before running bazel build for oppia-android, otherwise you will get an error.**
+
+### 6. Prepare the build environment
+
+Follow the instructions in [oppia-bazel-tools](https://github.com/oppia/oppia-bazel-tools#readme), in order to prepare your environment to support Oppia Android builds.
+
+### 7. Verify that the build is working
+
+At this point, your system should be able to build Oppia Android. To verify, try building the APK (from your subsystem terminal -- note that this and all other Bazel commands must be run from the root of the ‘oppia-android’ directory otherwise they will fail):
+
+```
+bazel build //:oppia
+```
+
+(Note that this command may take 10-20 minutes to complete depending on the performance of your machine).
+
+If everything is working, you should see output like the following:
+
+```
+Target //:oppia up-to-date:
+ bazel-bin/oppia_deploy.jar
+ bazel-bin/oppia_unsigned/apk
+ bazel-bin/oppia/apk
+INFO: Elapsed time: ...
+INFO: 1 process...
+INFO: Build completed successfully, ...
+```
+
+Note also that the ``oppia.apk`` under the ``bazel-bin`` directory of your local copy of Oppia Android should be a fully functioning development version of the app that can be installed using ``adb``
diff --git a/wiki/Bazel-Setup-Instructions-for-Windows.md b/wiki/Bazel-Setup-Instructions-for-Windows.md
index 7d757e63e1f..ebf203356df 100644
--- a/wiki/Bazel-Setup-Instructions-for-Windows.md
+++ b/wiki/Bazel-Setup-Instructions-for-Windows.md
@@ -59,6 +59,13 @@ JDK 8 is required for the Android build tools, and we suggest installing OpenJDK
sudo apt install openjdk-8-jdk-headless
```
+#### For Fedora 25+
+- Install JDK 8 by running this command on the terminal:
+```
+sudo dnf install java-1.8.0-openjdk
+```
+- Set the default Java version to jdk-8 by running the following command `sudo update-alternatives --config java` and selecting the number with jdk-8.
+
**Python 2**
Unfortunately, some of the Bazel build actions in the Android pipeline require Python 2 to be installed:
@@ -131,6 +138,15 @@ Follow [these instructions](https://docs.bazel.build/versions/main/install-ubunt
sudo apt install bazel-4.0.0
```
+#### For Fedora 25+
+
+- Install Bazelisk instead of Bazel using the command below in Fedora:
+```
+wget https://github.com/bazelbuild/bazelisk/releases/download/v1.8.1/bazelisk-linux-amd64
+chmod +x bazelisk-linux-amd64
+sudo mv bazelisk-linux-amd64 /usr/local/bin/bazel
+```
+
### 5. Preparing build environment for Oppia Android
The Oppia Android repository generally expects to live under an 'opensource' directory. While we recommend doing that in practice, we run into one complication when building the app on Windows: the repository itself lives under the native Windows filesystem & most of everything else needed to build lives under the Linux subsystem. To help simplify things, we prefer keeping just the repository on Windows and everything else on Linux, including the the Oppia Bazel toolchain. To prepare for this, we suggest making an 'opensource' directory in your Ubuntu subsystem:
diff --git a/wiki/Oppia-Bazel-Setup-Instructions.md b/wiki/Oppia-Bazel-Setup-Instructions.md
index 8805b67c9f8..fe23884521e 100644
--- a/wiki/Oppia-Bazel-Setup-Instructions.md
+++ b/wiki/Oppia-Bazel-Setup-Instructions.md
@@ -12,44 +12,19 @@
## Overview
Bazel is an open-source build and test tool similar to Make, Maven, and Gradle. It uses a human-readable, high-level build language.
-## Installation
+### Installation
**WARNING: We recommend to not use the Android Studio Bazel plugin since it currently has compatibility issues with the project.**
-**NOTE: If you're using Windows, please follow [these instructions](https://github.com/oppia/oppia-android/wiki/Bazel-Setup-Instructions-for-Windows) instead.**
+**Instructions for setting up Bazel on different Operating Systems:**
-Instructions for setting up Bazel on Unix-based machines:
+- [For Windows/Ubuntu/Fedora](https://github.com/oppia/oppia-android/wiki/Bazel-Setup-Instructions-for-Windows)
+- [For Mac including M1/M2](https://github.com/oppia/oppia-android/wiki/Bazel-Setup-Instructions-for-Mac)
+- [For Linux](https://github.com/oppia/oppia-android/wiki/Bazel-Setup-Instructions-for-Linux)
-1. Install Bazel from [here](https://docs.bazel.build/versions/master/install.html). Make sure that you follow the instructions for installing a specific version (Oppia Android requires 4.0.0 and won't build on other versions).
- - As of February 2023 we have verified that on Ubuntu (and similar systems) the [apt repository approach](https://bazel.build/install/ubuntu#install-on-ubuntu) works, you just need to make sure to do `sudo apt install bazel-4.0.0` as the latest command to install the correct version.
- - Note: if you find any errors related to `cURL`, please set up cURL on your machine. For Linux, you can use `sudo apt install curl`.
+### Building the app
-
-2. Oppia Android also requires OpenJDK 8. The Bazel installation instructions above include [sections on installing OpenJDK](https://docs.bazel.build/versions/main/tutorial/java.html#install-the-jdk) on different platforms.
-
- - For example, if you're using Ubuntu or another Debian-based system, you can run the following to install OpenJDK 8:
-
- ```sh
- sudo apt install openjdk-8-jdk
- ```
-
- - For MacOS M1, follow the instructions [here](https://installvirtual.com/install-openjdk-8-on-mac-using-brew-adoptopenjdk/). Note that, this requires installation of brew as a pre-requisite, which can be done by following the instructions [here](https://mac.install.guide/homebrew/index.html).
-
- You can confirm that this is set up using the command `java -version`, which should result in three lines being printed out with the first one showing "openjdk version "1.8.0_292".
-
-3. Ensure that you have Python 2 installed and make sure that it is currently active on your environment. You can do this by using the ``python --version`` command which should show Python 2.X.X. If it doesn’t, click [here](https://linuxconfig.org/install-python-2-on-ubuntu-20-04-focal-fossa-linux) for a resource on how to install and update Ubuntu to use Python 2 (other distros may vary in this step slightly).
-
-4. Ensure that your `ANDROID_HOME` environment variable is set to the location of your Android SDK. To do this, find the path to the installed SDK using Android Studio’s SDK Manager (install SDK 28). Assuming the SDK is installed to default locations, you can use the following commands to set the `ANDROID_HOME` variable:
- - Linux: `export ANDROID_HOME=$HOME/Android/Sdk/`
- - macOS: `export ANDROID_HOME=$HOME/Library/Android/sdk`
- - **Make sure you have the system environment variable set up** for ``ANDROID_HOME`` as you might have issues getting properly set up if not. If it isn’t set up (on Linux you can check by using ``echo $ANDROID_HOME`` in a new terminal; it should output the correct path to your Android SDK), on Linux you can move the ``export`` from above to your ``~/.bashrc`` file to make it permanent (you can apply the change immediately using ``source ~/.bashrc``).
-
-5. Follow the instructions in [oppia-bazel-tools](https://github.com/oppia/oppia-bazel-tools).
-
-
-#### Building the app
-
-After the installation, completes you can build the app using Bazel.
+After the installation completes you can build the app using Bazel.
**Move your command line head to the `~/opensource/oppia-android`**, then run the below bazel command:
From f93c06bb5a5681922c9796aadd8cb39e355b4832 Mon Sep 17 00:00:00 2001
From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com>
Date: Tue, 4 Jul 2023 04:10:21 -0600
Subject: [PATCH 03/33] Fix #4909 : Remove the unused condition from various
lessons_chapters_view for Accessibility (#4910)
## Explanation
Fixed #4909 : Remove the unused conditions from
`lessons_no_started_chapter_view.xml`
`lessons_in_progress_chapter_view.xml` `lessons_locked_chapter_view.xml`
for Accessibility.
Fixed a bug where TalkBack would read chapter names twice when the chapter is locked, and also improved the feedback read out when a locked chapter is clicked
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [ ] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---
.../topic/lessons/ChapterSummaryViewModel.kt | 6 +++-
.../lessons_in_progress_chapter_view.xml | 4 +--
.../layout/lessons_locked_chapter_view.xml | 4 +--
.../lessons_not_started_chapter_view.xml | 4 +--
app/src/main/res/values/strings.xml | 1 +
.../topic/lessons/TopicLessonsFragmentTest.kt | 32 ++++++++++++-------
6 files changed, 33 insertions(+), 18 deletions(-)
diff --git a/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt
index 33a0fb04bc4..857bf87d91a 100644
--- a/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt
+++ b/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt
@@ -33,7 +33,11 @@ class ChapterSummaryViewModel(
ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES -> {
if (previousChapterTitle != null) {
resourceHandler.getStringInLocaleWithWrapping(
- R.string.chapter_prerequisite_title_label, index.toString(), previousChapterTitle
+ R.string.chapter_locked_prerequisite_title_label,
+ (index + 1).toString(),
+ chapterTitle,
+ index.toString(),
+ previousChapterTitle
)
} else {
resourceHandler.getStringInLocaleWithWrapping(
diff --git a/app/src/main/res/layout/lessons_in_progress_chapter_view.xml b/app/src/main/res/layout/lessons_in_progress_chapter_view.xml
index 057e550d1d1..dfd3b226eda 100644
--- a/app/src/main/res/layout/lessons_in_progress_chapter_view.xml
+++ b/app/src/main/res/layout/lessons_in_progress_chapter_view.xml
@@ -34,7 +34,7 @@
android:layout_gravity="center"
android:layout_marginStart="10dp"
android:fontFamily="sans-serif"
- android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}"
+ android:importantForAccessibility="no"
android:minWidth="20dp"
android:minHeight="20dp"
android:text="@{viewModel.computePlayChapterIndexText()}"
@@ -59,7 +59,7 @@
android:background="@drawable/chapter_white_bg_with_bright_green_border"
android:fontFamily="sans-serif"
android:gravity="center|start"
- android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}"
+ android:importantForAccessibility="no"
android:minHeight="48dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
diff --git a/app/src/main/res/layout/lessons_locked_chapter_view.xml b/app/src/main/res/layout/lessons_locked_chapter_view.xml
index 78a04c5938e..c5d8c7d4f7e 100644
--- a/app/src/main/res/layout/lessons_locked_chapter_view.xml
+++ b/app/src/main/res/layout/lessons_locked_chapter_view.xml
@@ -35,7 +35,7 @@
android:layout_gravity="center"
android:layout_marginStart="10dp"
android:fontFamily="sans-serif"
- android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}"
+ android:importantForAccessibility="no"
android:minWidth="20dp"
android:minHeight="20dp"
android:text="@{viewModel.computePlayChapterIndexText()}"
@@ -65,7 +65,7 @@
android:background="@color/component_color_lessons_tab_activity_lessons_locked_chapter_name_background_color"
android:fontFamily="sans-serif"
android:gravity="center|start"
- android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}"
+ android:importantForAccessibility="no"
android:minHeight="48dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
diff --git a/app/src/main/res/layout/lessons_not_started_chapter_view.xml b/app/src/main/res/layout/lessons_not_started_chapter_view.xml
index f2038b60e8f..2db54e99d00 100644
--- a/app/src/main/res/layout/lessons_not_started_chapter_view.xml
+++ b/app/src/main/res/layout/lessons_not_started_chapter_view.xml
@@ -30,7 +30,7 @@
android:background="@drawable/chapter_dark_green_bg_with_bright_green_border"
android:fontFamily="sans-serif"
android:gravity="center"
- android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}"
+ android:importantForAccessibility="no"
android:minWidth="60dp"
android:minHeight="48dp"
android:paddingStart="8dp"
@@ -48,7 +48,7 @@
android:layout_height="0dp"
android:fontFamily="sans-serif"
android:gravity="center|start"
- android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}"
+ android:importantForAccessibility="no"
android:minHeight="48dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 370de593631..ed1a4bca9fd 100755
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -90,6 +90,7 @@
Chapter %s with title %s is completedChapter %s with title %s is in progressComplete Chapter %s: %s to unlock this chapter.
+ Chapter %s: %s is currently locked. Please complete chapter %s: %s to unlock this chapter.Complete the previous chapter to unlock this chapter.Enter text.Enter a fraction in the form x/x, or a mixed number in the form x x/x.
diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt
index babec426922..8ea32a5e1ab 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt
@@ -152,16 +152,25 @@ import javax.inject.Singleton
qualifiers = "port-xxhdpi"
)
class TopicLessonsFragmentTest {
- @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
- @get:Rule val oppiaTestRule = OppiaTestRule()
-
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
- @Inject lateinit var storyProgressTestHelper: StoryProgressTestHelper
- @Inject lateinit var fakeOppiaClock: FakeOppiaClock
- @Inject lateinit var fakeAccessibilityService: FakeAccessibilityService
- @Inject lateinit var spotlightStateController: SpotlightStateController
- @Inject lateinit var explorationCheckpointTestHelper: ExplorationCheckpointTestHelper
- @Inject lateinit var fakeExplorationRetriever: FakeExplorationRetriever
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
+ @get:Rule
+ val oppiaTestRule = OppiaTestRule()
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+ @Inject
+ lateinit var storyProgressTestHelper: StoryProgressTestHelper
+ @Inject
+ lateinit var fakeOppiaClock: FakeOppiaClock
+ @Inject
+ lateinit var fakeAccessibilityService: FakeAccessibilityService
+ @Inject
+ lateinit var spotlightStateController: SpotlightStateController
+ @Inject
+ lateinit var explorationCheckpointTestHelper: ExplorationCheckpointTestHelper
+ @Inject
+ lateinit var fakeExplorationRetriever: FakeExplorationRetriever
@field:[Inject EnableExtraTopicTabsUi]
lateinit var enableExtraTopicTabsUiValue: PlatformParameterValue
@@ -366,7 +375,8 @@ class TopicLessonsFragmentTest {
.check(
matches(
withContentDescription(
- "Complete Chapter 1: What is a Ratio? to unlock this chapter."
+ "Chapter 2: Order is important is currently locked. Please complete chapter 1: " +
+ "What is a Ratio? to unlock this chapter."
)
)
)
From 67c1d7940ab7aa82f6105443b8d78f81b51430c2 Mon Sep 17 00:00:00 2001
From: Adhiambo Peres <59600948+adhiamboperes@users.noreply.github.com>
Date: Fri, 7 Jul 2023 12:41:51 +0300
Subject: [PATCH 04/33] Fix#4881: Build UI for the Android NPS Survey (#4945)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Explanation
Fixes #4881.
This is PR 3 of 6 that adds the UI for the NPS survey as well as
completes the [key user
flows](https://docs.google.com/document/d/1aIr7cu7V5-9uvhfDgekq-iOXF57hbxEmjiL3mElly0I/edit#heading=h.rkqdyq11md2s).
### Layouts Introduced
#### Main Layouts
- Survey intro
- Survey fragment for displaying the questions
- Exit confirmation dialog
- Thank you screen
#### Technical decisions
I used the `BindableAdapter.MultiTypeBuilder` from within the fragment
to populate the views. This allowed me to reuse the survey question
layout while creating individual layouts for each answer option group.
I created a viewmodel hierachy where:
- The root fragment viewmodel is responsible for retrieving the correct
question text, making the decision to allow navigation to the next
question by enabling/disabling the next button based on selected answer
availability, and updating the progressbar percentage and progress.
- For the answer option layouts, I created a Super-class,
`SurveyAnswerItemViewModel` for representing the different types of
different layouts that can exist for the recyclerView responsible for
displaying these views.
- Each question option layout has it's own viewmodel that is responsible
for retrieving the answer options to display from the app strings file,
notifying that an answer has been provided, and retrieving the selected
answer for handling.
I used view binding to display all the strings and control navigation.
I also created custom views where using regular views proved to be
complex to work with.
#### Custom Views
- SurveyOnboardingBackgroundView - for creating the custom background
needed on the intro and outro layouts
- SurveyMultipleChoiceOptionView - for binding lists in multiple choice
questions
- SurveyNpsItemOptionView - for binding the custom layout needed for the
NPS score question
### Navigation and Progress
I found it difficult to decouple the UI development work from the
progress work, since the survey and the questions are generated
dynaically so I needed to develop both in tandem.
The following changes pertain to the core survey logic:
#### SurveyController
Creates a survey with a list of questions.
#### SurveyProgressController
Tracks the non-persisted progress of a survey and is responsible for
handling the survey session, managing the commands for navigating
through and answer submission.
I opted to use a command pattern similar to what is used in the
[ExplorationProgressController](https://github.com/oppia/oppia-android/blob/develop/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt).
#### Navigation
The survey can be viewed as a series of linear questions, which we will
navigate through using methods defined in the SurveyQuestionDeck,
modeled after the
[StateDeck](https://github.com/oppia/oppia-android/blob/develop/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt).
To start with, we will create an EphemeralSurveyQuestion which
represents the question the user is currently viewing. It contains a
reference to the next and previous questions if applicable, and a
reference to whether it is the last question in the deck. The
SurveyQuestionDeck will define the conditions for navigating to the
previous and next questions as well as the exceptions thrown in case a
navigation action is invalid.
A SurveyQuestionGraph class will be created to provide functionality for
processing the behavior of a ‘next’ navigation action, which evaluates
which question is shown next.
### Survey Proto Changes:
- Refactored the order of the enum items in the `UserTypeAnswer` enum so
that they result into a properly indexed options list per the mocks.
- Modified the `EphemeralSurveyQuestion` to have a
`current_question_index` and a `total_question_count` to make it easy to
compute the survey progress.
- Added answer typecase `free_form_answer` to the `SurveySelectedAnswer`
message to hold the value of a free text response.
- Added ` SurveyQuestion optional_question = 3;` field to the survey so
that we have a clear seperation between the mandatory and optional
questions. This also supports the survey generation architecture.
### Bazel
Created `BUILD` definitions for all new classes and tests.
### Dagger
I changed [approximately 150 test
files](https://pbs.twimg.com/media/EAeaeOnU4AcXRQw.jpg) to include
`SurveyQuestionModule::class`, though I removed it in a subsequent
refactor. When I removed the module and it's usages, it was not a very
clean revert because some of the files were not properly formatted
before, but they are now -- hence some test files here with only
formatting changes.
### Usages, Future Work and Out of Scope
The domain layer of the survey is pretty much flexible to support any
survey question, including should the current list be reordered or the
number of questions changed. For example, if we wanted to show a
subsequent survey on the same profile:
#### Show One Question Only
We would be able to show only one question if we only passed that one
question name to the list in the `startSurveySession()` function. We
would also need to set `showOptionalQuestion` to false, to disable
adding the free-form question to the survey. This is otherwise created
by default as `showOptionalQuestion` is always true otherwise.
Further work would need to be done in the UI to to update the navigation
button behaviour, i.e currently the button will show next instead of
submit, and to ensure the submit answer functionality works to exit
survey once the one answer has been submitted.
#### Show Two or More Questions
We could for example want to show only the NPS score question and the
feedback question. We would need to pass in the NPS question's name as
an input to the `startSurveySession()` function. The feedback question
name never needs to be explicitly passed since we create a default
(promoter) question and typically update the actual question based on
the NPS score.
The UI currently supports this scenario.
#### Add More Question Types
The domain layer would be able to handle any question types, but the UI
will need to be updated to display this question by creating a new
layout, and viewmodel for it, as well as updating the
`SurveyAnswerItemViewModel`'s `ViewType` enum with this new viewType(s).
A more longterm solution for a dynamic UI would be to create
standardized survey questions and answer options, pretty much like
state/exploration interactions.
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL guide]
| LTR | RTL |
|---|---|
|https://github.com/oppia/oppia-android/assets/59600948/d2411422-aa99-482d-a0e7-c6fd163e85e1|
https://github.com/oppia/oppia-android/assets/59600948/329e0672-8d4c-4f43-8c6c-b9036a78980a|
|Updated LTR video with restored answers|
|https://github.com/oppia/oppia-android/assets/59600948/1a0decff-7dbf-4a6a-a225-5f38f4191fd6|
---
app/BUILD.bazel | 14 +
app/src/main/AndroidManifest.xml | 5 +
.../app/activity/ActivityComponentImpl.kt | 2 +
.../SurveyOnboardingBackgroundView.kt | 110 +++
.../app/fragment/FragmentComponentImpl.kt | 8 +
.../ExplorationActivityPresenter.kt | 57 +-
.../player/state/StateFragmentPresenter.kt | 54 +-
.../oppia/android/app/shim/ViewBindingShim.kt | 39 +
.../android/app/shim/ViewBindingShimImpl.kt | 47 ++
.../ExitSurveyConfirmationDialogFragment.kt | 75 ++
...rveyConfirmationDialogFragmentPresenter.kt | 77 ++
.../SelectedAnswerAvailabilityReceiver.kt | 29 +
.../android/app/survey/SurveyActivity.kt | 65 ++
.../app/survey/SurveyActivityPresenter.kt | 64 ++
.../android/app/survey/SurveyFragment.kt | 77 ++
.../app/survey/SurveyFragmentPresenter.kt | 335 +++++++++
.../survey/SurveyMultipleChoiceOptionView.kt | 79 +++
.../app/survey/SurveyNpsItemOptionView.kt | 79 +++
.../app/survey/SurveyOutroDialogFragment.kt | 54 ++
.../SurveyOutroDialogFragmentPresenter.kt | 74 ++
.../android/app/survey/SurveyViewModel.kt | 97 +++
.../app/survey/SurveyWelcomeDialogFragment.kt | 100 +++
.../SurveyWelcomeDialogFragmentPresenter.kt | 87 +++
.../FreeFormItemsViewModel.kt | 60 ++
.../MarketFitItemsViewModel.kt | 153 ++++
.../MultipleChoiceOptionContentViewModel.kt | 22 +
.../surveyitemviewmodel/NpsItemsViewModel.kt | 99 +++
.../SurveyAnswerItemViewModel.kt | 22 +
.../UserTypeItemsViewModel.kt | 153 ++++
.../android/app/view/ViewComponentImpl.kt | 6 +
...nt_color_shared_survey_option_selector.xml | 6 +
.../rounded_button_white_background_color.xml | 9 +
.../rounded_button_white_outline_color.xml | 9 +
...unded_primary_button_grey_shadow_color.xml | 16 +
.../survey_confirmation_dialog_background.xml | 9 +
.../survey_edit_text_background_border.xml | 9 +
.../survey_next_button_background.xml | 7 +
.../survey_nps_radio_button_background.xml | 7 +
.../survey_nps_radio_selected_color.xml | 10 +
.../drawable/survey_nps_radio_text_color.xml | 5 +
.../survey_nps_radio_unselected_color.xml | 10 +
.../main/res/drawable/survey_progress_bar.xml | 20 +
...vey_rounded_disabled_button_background.xml | 9 +
.../survey_submit_button_background.xml | 7 +
app/src/main/res/layout/survey_activity.xml | 16 +
.../survey_exit_confirmation_dialog.xml | 39 +
app/src/main/res/layout/survey_fragment.xml | 139 ++++
.../res/layout/survey_free_form_layout.xml | 42 ++
.../survey_market_fit_question_layout.xml | 17 +
.../layout/survey_multiple_choice_item.xml | 47 ++
app/src/main/res/layout/survey_nps_item.xml | 41 ++
.../res/layout/survey_nps_score_layout.xml | 39 +
.../layout/survey_outro_dialog_fragment.xml | 55 ++
.../survey_user_type_question_layout.xml | 17 +
.../layout/survey_welcome_dialog_fragment.xml | 67 ++
app/src/main/res/values/color_defs.xml | 6 +
app/src/main/res/values/color_palette.xml | 9 +
app/src/main/res/values/component_colors.xml | 12 +
app/src/main/res/values/dimens.xml | 26 +-
app/src/main/res/values/strings.xml | 31 +
app/src/main/res/values/styles.xml | 151 ++++
.../ProfileAndDeviceIdActivityTest.kt | 31 +-
.../devoptions/ViewEventLogsFragmentTest.kt | 23 +-
.../android/app/faq/FaqListActivityTest.kt | 1 -
.../ProfilePictureActivityTest.kt | 2 +-
.../ProfileProgressActivityTest.kt | 2 +-
.../ProfileProgressFragmentTest.kt | 2 +-
.../android/app/splash/SplashActivityTest.kt | 26 +-
.../android/app/survey/SurveyActivityTest.kt | 224 ++++++
.../android/app/survey/SurveyFragmentTest.kt | 628 +++++++++++++++++
.../android/app/home/HomeActivityLocalTest.kt | 10 +-
.../ExplorationActivityLocalTest.kt | 2 +-
.../ActivityLanguageLocaleHandlerTest.kt | 5 +-
.../app/utility/datetime/DateTimeUtilTest.kt | 7 +-
.../PlatformParameterAlphaKenyaModule.kt | 4 +-
.../PlatformParameterAlphaModule.kt | 30 +
.../PlatformParameterModule.kt | 4 +-
.../oppia/android/domain/survey/BUILD.bazel | 43 ++
.../android/domain/survey/SurveyController.kt | 126 ++++
.../domain/survey/SurveyGatingController.kt | 4 +-
.../android/domain/survey/SurveyProgress.kt | 112 +++
.../domain/survey/SurveyProgressController.kt | 667 ++++++++++++++++++
.../domain/survey/SurveyQuestionDeck.kt | 94 +++
.../domain/survey/SurveyQuestionGraph.kt | 27 +
.../domain/audio/AudioPlayerControllerTest.kt | 2 +-
.../ExplorationCheckpointControllerTest.kt | 2 +-
.../analytics/LearnerAnalyticsLoggerTest.kt | 31 +-
.../oppia/android/domain/survey/BUILD.bazel | 54 ++
.../domain/survey/SurveyControllerTest.kt | 233 ++++++
.../survey/SurveyProgressControllerTest.kt | 495 +++++++++++++
model/src/main/proto/BUILD.bazel | 1 +
model/src/main/proto/arguments.proto | 8 +
model/src/main/proto/screens.proto | 3 +
model/src/main/proto/survey.proto | 48 +-
.../assets/kdoc_validity_exemptions.textproto | 15 +
scripts/assets/test_file_exemptions.textproto | 23 +
.../TestPlatformParameterModule.kt | 4 +-
.../util/logging/EventBundleCreator.kt | 1 +
.../PlatformParameterConstants.kt | 2 +-
99 files changed, 5945 insertions(+), 81 deletions(-)
create mode 100644 app/src/main/java/org/oppia/android/app/customview/SurveyOnboardingBackgroundView.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt
create mode 100644 app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt
create mode 100644 app/src/main/res/color/component_color_shared_survey_option_selector.xml
create mode 100644 app/src/main/res/drawable/rounded_button_white_background_color.xml
create mode 100644 app/src/main/res/drawable/rounded_button_white_outline_color.xml
create mode 100644 app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml
create mode 100644 app/src/main/res/drawable/survey_confirmation_dialog_background.xml
create mode 100644 app/src/main/res/drawable/survey_edit_text_background_border.xml
create mode 100644 app/src/main/res/drawable/survey_next_button_background.xml
create mode 100644 app/src/main/res/drawable/survey_nps_radio_button_background.xml
create mode 100644 app/src/main/res/drawable/survey_nps_radio_selected_color.xml
create mode 100644 app/src/main/res/drawable/survey_nps_radio_text_color.xml
create mode 100644 app/src/main/res/drawable/survey_nps_radio_unselected_color.xml
create mode 100644 app/src/main/res/drawable/survey_progress_bar.xml
create mode 100644 app/src/main/res/drawable/survey_rounded_disabled_button_background.xml
create mode 100644 app/src/main/res/drawable/survey_submit_button_background.xml
create mode 100644 app/src/main/res/layout/survey_activity.xml
create mode 100644 app/src/main/res/layout/survey_exit_confirmation_dialog.xml
create mode 100644 app/src/main/res/layout/survey_fragment.xml
create mode 100644 app/src/main/res/layout/survey_free_form_layout.xml
create mode 100644 app/src/main/res/layout/survey_market_fit_question_layout.xml
create mode 100644 app/src/main/res/layout/survey_multiple_choice_item.xml
create mode 100644 app/src/main/res/layout/survey_nps_item.xml
create mode 100644 app/src/main/res/layout/survey_nps_score_layout.xml
create mode 100644 app/src/main/res/layout/survey_outro_dialog_fragment.xml
create mode 100644 app/src/main/res/layout/survey_user_type_question_layout.xml
create mode 100644 app/src/main/res/layout/survey_welcome_dialog_fragment.xml
create mode 100644 app/src/sharedTest/java/org/oppia/android/app/survey/SurveyActivityTest.kt
create mode 100644 app/src/sharedTest/java/org/oppia/android/app/survey/SurveyFragmentTest.kt
create mode 100644 domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt
create mode 100644 domain/src/main/java/org/oppia/android/domain/survey/SurveyProgress.kt
create mode 100644 domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt
create mode 100644 domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt
create mode 100644 domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionGraph.kt
create mode 100644 domain/src/test/java/org/oppia/android/domain/survey/SurveyControllerTest.kt
create mode 100644 domain/src/test/java/org/oppia/android/domain/survey/SurveyProgressControllerTest.kt
diff --git a/app/BUILD.bazel b/app/BUILD.bazel
index 6dafe8a115d..d5a4bfe97fe 100644
--- a/app/BUILD.bazel
+++ b/app/BUILD.bazel
@@ -155,6 +155,7 @@ LISTENERS = [
"src/main/java/org/oppia/android/app/recyclerview/OnItemDragListener.kt",
"src/main/java/org/oppia/android/app/settings/profile/LoadProfileEditDeletionDialogListener.kt",
"src/main/java/org/oppia/android/app/settings/profile/RouteToProfileEditListener.kt",
+ "src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt",
"src/main/java/org/oppia/android/app/topic/RouteToRevisionCardListener.kt",
"src/main/java/org/oppia/android/app/topic/lessons/ChapterSummarySelector.kt",
"src/main/java/org/oppia/android/app/topic/lessons/StorySummarySelector.kt",
@@ -235,6 +236,12 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [
"src/main/java/org/oppia/android/app/profileprogress/RecentlyPlayedStorySummaryViewModel.kt",
"src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt",
"src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryHeaderViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt",
"src/main/java/org/oppia/android/app/topic/TopicViewModel.kt",
"src/main/java/org/oppia/android/app/topic/info/TopicInfoViewModel.kt",
"src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt",
@@ -342,6 +349,7 @@ VIEW_MODELS = [
"src/main/java/org/oppia/android/app/story/StoryFragmentScroller.kt",
"src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryItemViewModel.kt",
"src/main/java/org/oppia/android/app/story/StoryViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt",
"src/main/java/org/oppia/android/app/testing/BindableAdapterTestDataModel.kt",
"src/main/java/org/oppia/android/app/testing/BindableAdapterTestViewModel.kt",
"src/main/java/org/oppia/android/app/testing/CircularProgressIndicatorAdaptersTestViewModel.kt",
@@ -402,7 +410,10 @@ VIEWS_WITH_RESOURCE_IMPORTS = [
"src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt",
"src/main/java/org/oppia/android/app/customview/PromotedStoryCardView.kt",
"src/main/java/org/oppia/android/app/customview/SegmentedCircularProgressView.kt",
+ "src/main/java/org/oppia/android/app/customview/SurveyOnboardingBackgroundView.kt",
"src/main/java/org/oppia/android/app/customview/VerticalDashedLineView.kt",
+ "src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt",
+ "src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt",
"src/main/java/org/oppia/android/app/utility/ClickableAreasImage.kt",
]
@@ -621,6 +632,7 @@ kt_android_library(
"//domain/src/main/java/org/oppia/android/domain/audio:cellular_audio_dialog_controller",
"//model/src/main/proto:arguments_java_proto_lite",
"//model/src/main/proto:question_java_proto_lite",
+ "//model/src/main/proto:survey_java_proto_lite",
"//model/src/main/proto:topic_java_proto_lite",
"//third_party:androidx_recyclerview_recyclerview",
],
@@ -781,6 +793,8 @@ kt_android_library(
"//domain/src/main/java/org/oppia/android/domain/oppialogger:startup_listener",
"//domain/src/main/java/org/oppia/android/domain/profile:profile_management_controller",
"//domain/src/main/java/org/oppia/android/domain/spotlight:spotlight_state_controller",
+ "//domain/src/main/java/org/oppia/android/domain/survey:gating_controller",
+ "//domain/src/main/java/org/oppia/android/domain/survey:survey_controller",
"//model/src/main/proto:arguments_java_proto_lite",
"//third_party:androidx_databinding_databinding-adapters",
"//third_party:androidx_databinding_databinding-common",
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1f1fc369830..bad515549bb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -307,6 +307,11 @@
+ (this) as ViewComponentFactory
+ val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl
+ viewComponent.inject(this)
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt
index 36b83c8244d..d89e5b1297b 100644
--- a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt
+++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt
@@ -65,6 +65,10 @@ import org.oppia.android.app.shim.IntentFactoryShimModule
import org.oppia.android.app.shim.ViewBindingShimModule
import org.oppia.android.app.spotlight.SpotlightFragment
import org.oppia.android.app.story.StoryFragment
+import org.oppia.android.app.survey.ExitSurveyConfirmationDialogFragment
+import org.oppia.android.app.survey.SurveyFragment
+import org.oppia.android.app.survey.SurveyOutroDialogFragment
+import org.oppia.android.app.survey.SurveyWelcomeDialogFragment
import org.oppia.android.app.testing.DragDropTestFragment
import org.oppia.android.app.testing.ExplorationTestActivityPresenter
import org.oppia.android.app.testing.ImageRegionSelectionTestFragment
@@ -177,4 +181,8 @@ interface FragmentComponentImpl : FragmentComponent, ViewComponentBuilderInjecto
fun inject(walkthroughFinalFragment: WalkthroughFinalFragment)
fun inject(walkthroughTopicListFragment: WalkthroughTopicListFragment)
fun inject(walkthroughWelcomeFragment: WalkthroughWelcomeFragment)
+ fun inject(surveyFragment: SurveyFragment)
+ fun inject(exitSurveyConfirmationDialogFragment: ExitSurveyConfirmationDialogFragment)
+ fun inject(surveyWelcomeDialogFragment: SurveyWelcomeDialogFragment)
+ fun inject(surveyOutroDialogFragment: SurveyOutroDialogFragment)
}
diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt
index f2476a44c3d..cdef02a2692 100644
--- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt
@@ -19,6 +19,7 @@ import org.oppia.android.app.model.ExplorationActivityParams
import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.ReadingTextSize
import org.oppia.android.app.model.Spotlight
+import org.oppia.android.app.model.SurveyQuestionName
import org.oppia.android.app.options.OptionsActivity
import org.oppia.android.app.player.stopplaying.ProgressDatabaseFullDialogFragment
import org.oppia.android.app.player.stopplaying.UnsavedExplorationDialogFragment
@@ -26,6 +27,8 @@ import org.oppia.android.app.spotlight.SpotlightFragment
import org.oppia.android.app.spotlight.SpotlightManager
import org.oppia.android.app.spotlight.SpotlightShape
import org.oppia.android.app.spotlight.SpotlightTarget
+import org.oppia.android.app.survey.SurveyWelcomeDialogFragment
+import org.oppia.android.app.survey.TAG_SURVEY_WELCOME_DIALOG
import org.oppia.android.app.topic.TopicActivity
import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.app.utility.FontScaleConfigurationUtil
@@ -33,6 +36,7 @@ import org.oppia.android.app.viewmodel.ViewModelProvider
import org.oppia.android.databinding.ExplorationActivityBinding
import org.oppia.android.domain.exploration.ExplorationDataController
import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.survey.SurveyGatingController
import org.oppia.android.domain.translation.TranslationController
import org.oppia.android.util.data.AsyncResult
import org.oppia.android.util.data.DataProviders.Companion.toLiveData
@@ -54,7 +58,8 @@ class ExplorationActivityPresenter @Inject constructor(
private val fontScaleConfigurationUtil: FontScaleConfigurationUtil,
private val translationController: TranslationController,
private val oppiaLogger: OppiaLogger,
- private val resourceHandler: AppLanguageResourceHandler
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val surveyGatingController: SurveyGatingController
) {
private lateinit var explorationToolbar: Toolbar
private lateinit var explorationToolbarTitle: TextView
@@ -279,8 +284,9 @@ class ExplorationActivityPresenter @Inject constructor(
oppiaLogger.e("ExplorationActivity", "Failed to stop exploration", it.error)
is AsyncResult.Success -> {
oppiaLogger.d("ExplorationActivity", "Successfully stopped exploration")
- backPressActivitySelector()
- (activity as ExplorationActivity).finish()
+ if (isCompletion) {
+ maybeShowSurveyDialog(profileId, topicId)
+ }
}
}
}
@@ -304,6 +310,8 @@ class ExplorationActivityPresenter @Inject constructor(
* current exploration.
*/
fun backButtonPressed() {
+ // check if survey should be shown
+ maybeShowSurveyDialog(profileId, topicId)
// If checkpointing is not enabled, show StopExplorationDialogFragment to exit the exploration,
// this is expected to happen if the exploration is marked as completed.
if (!isCheckpointingEnabled) {
@@ -500,4 +508,47 @@ class ExplorationActivityPresenter @Inject constructor(
}
}
}
+
+ private fun maybeShowSurveyDialog(profileId: ProfileId, topicId: String) {
+ surveyGatingController.maybeShowSurvey(profileId, topicId).toLiveData()
+ .observe(
+ activity,
+ { gatingResult ->
+ when (gatingResult) {
+ is AsyncResult.Pending -> {
+ oppiaLogger.d("ExplorationActivity", "A gating decision is pending")
+ }
+ is AsyncResult.Failure -> {
+ oppiaLogger.e(
+ "ExplorationActivity",
+ "Failed to retrieve gating decision",
+ gatingResult.error
+ )
+ backPressActivitySelector()
+ }
+ is AsyncResult.Success -> {
+ if (gatingResult.value) {
+ val dialogFragment =
+ SurveyWelcomeDialogFragment.newInstance(profileId, topicId, SURVEY_QUESTIONS)
+ val transaction = activity.supportFragmentManager.beginTransaction()
+ transaction
+ .add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG)
+ .addToBackStack(null)
+ .commit()
+ } else {
+ backPressActivitySelector()
+ }
+ }
+ }
+ }
+ )
+ }
+
+ companion object {
+ private val SURVEY_QUESTIONS = listOf(
+ SurveyQuestionName.USER_TYPE,
+ SurveyQuestionName.MARKET_FIT,
+ SurveyQuestionName.NPS
+ )
+ }
}
diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt
index 304694c7b2e..b0c7687ac28 100755
--- a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt
@@ -25,6 +25,7 @@ import org.oppia.android.app.model.EphemeralState
import org.oppia.android.app.model.HelpIndex
import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.State
+import org.oppia.android.app.model.SurveyQuestionName
import org.oppia.android.app.model.UserAnswer
import org.oppia.android.app.player.audio.AudioButtonListener
import org.oppia.android.app.player.audio.AudioFragment
@@ -34,6 +35,8 @@ import org.oppia.android.app.player.state.ConfettiConfig.MEDIUM_CONFETTI_BURST
import org.oppia.android.app.player.state.ConfettiConfig.MINI_CONFETTI_BURST
import org.oppia.android.app.player.state.listener.RouteToHintsAndSolutionListener
import org.oppia.android.app.player.stopplaying.StopStatePlayingSessionWithSavedProgressListener
+import org.oppia.android.app.survey.SurveyWelcomeDialogFragment
+import org.oppia.android.app.survey.TAG_SURVEY_WELCOME_DIALOG
import org.oppia.android.app.topic.conceptcard.ConceptCardFragment
import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.app.utility.SplitScreenManager
@@ -41,6 +44,7 @@ import org.oppia.android.app.utility.lifecycle.LifecycleSafeTimerFactory
import org.oppia.android.databinding.StateFragmentBinding
import org.oppia.android.domain.exploration.ExplorationProgressController
import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.survey.SurveyGatingController
import org.oppia.android.domain.topic.StoryProgressController
import org.oppia.android.util.accessibility.AccessibilityService
import org.oppia.android.util.data.AsyncResult
@@ -76,7 +80,8 @@ class StateFragmentPresenter @Inject constructor(
private val oppiaClock: OppiaClock,
private val viewModel: StateViewModel,
private val accessibilityService: AccessibilityService,
- private val resourceHandler: AppLanguageResourceHandler
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val surveyGatingController: SurveyGatingController
) {
private val routeToHintsAndSolutionListener = activity as RouteToHintsAndSolutionListener
@@ -184,8 +189,7 @@ class StateFragmentPresenter @Inject constructor(
fun onReturnToTopicButtonClicked() {
hideKeyboard()
markExplorationCompleted()
- (activity as StopStatePlayingSessionWithSavedProgressListener)
- .deleteCurrentProgressAndStopSession(isCompletion = true)
+ maybeShowSurveyDialog(profileId, topicId)
}
private fun showOrHideAudioByState(state: State) {
@@ -524,6 +528,42 @@ class StateFragmentPresenter @Inject constructor(
}
}
+ private fun maybeShowSurveyDialog(profileId: ProfileId, topicId: String) {
+ surveyGatingController.maybeShowSurvey(profileId, topicId).toLiveData()
+ .observe(
+ activity,
+ { gatingResult ->
+ when (gatingResult) {
+ is AsyncResult.Pending -> {
+ oppiaLogger.d("StateFragment", "A gating decision is pending")
+ }
+ is AsyncResult.Failure -> {
+ oppiaLogger.e(
+ "StateFragment",
+ "Failed to retrieve gating decision",
+ gatingResult.error
+ )
+ (activity as StopStatePlayingSessionWithSavedProgressListener)
+ .deleteCurrentProgressAndStopSession(isCompletion = true)
+ }
+ is AsyncResult.Success -> {
+ if (gatingResult.value) {
+ val dialogFragment =
+ SurveyWelcomeDialogFragment.newInstance(profileId, topicId, SURVEY_QUESTIONS)
+ val transaction = activity.supportFragmentManager.beginTransaction()
+ transaction
+ .add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG)
+ .commitNow()
+ } else {
+ (activity as StopStatePlayingSessionWithSavedProgressListener)
+ .deleteCurrentProgressAndStopSession(isCompletion = true)
+ }
+ }
+ }
+ }
+ )
+ }
+
/**
* An [Interpolator] when performs a reversed, then regular bounce interpolation using
* [BounceInterpolator].
@@ -545,4 +585,12 @@ class StateFragmentPresenter @Inject constructor(
} else bounceInterpolator.getInterpolation(input * 2f - 1f)
}
}
+
+ companion object {
+ private val SURVEY_QUESTIONS = listOf(
+ SurveyQuestionName.USER_TYPE,
+ SurveyQuestionName.MARKET_FIT,
+ SurveyQuestionName.NPS
+ )
+ }
}
diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt
index cb57334b06d..acc6efcec4a 100644
--- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt
+++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt
@@ -12,6 +12,7 @@ import org.oppia.android.app.home.promotedlist.PromotedStoryViewModel
import org.oppia.android.app.model.WrittenTranslationContext
import org.oppia.android.app.player.state.itemviewmodel.DragDropInteractionContentViewModel
import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionContentViewModel
+import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel
import org.oppia.android.util.parser.html.HtmlParser
/**
@@ -167,4 +168,42 @@ interface ViewBindingShim {
entityId: String,
writtenTranslationContext: WrittenTranslationContext
)
+
+ /**
+ * Handles binding inflation for [SurveyMultipleChoiceOptionView]'s MultipleChoiceOption and
+ * returns the binding's view.
+ */
+ fun provideMultipleChoiceItemsInflatedView(
+ inflater: LayoutInflater,
+ parent: ViewGroup,
+ attachToParent: Boolean
+ ): View
+
+ /**
+ * Handles binding inflation for [SurveyMultipleChoiceOptionView]'s MultipleChoiceOption and
+ * returns the binding's view model.
+ */
+ fun provideMultipleChoiceOptionViewModel(
+ view: View,
+ viewModel: MultipleChoiceOptionContentViewModel
+ )
+
+ /**
+ * Handles binding inflation for [SurveyNpsItemOptionView]'s MultipleChoiceOption and
+ * returns the binding's view.
+ */
+ fun provideNpsItemsInflatedView(
+ inflater: LayoutInflater,
+ parent: ViewGroup,
+ attachToParent: Boolean
+ ): View
+
+ /**
+ * Handles binding inflation for [SurveyNpsItemOptionView]'s MultipleChoiceOption and
+ * returns the binding's view model.
+ */
+ fun provideNpsItemsViewModel(
+ view: View,
+ viewModel: MultipleChoiceOptionContentViewModel
+ )
}
diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt
index cd577465e07..69b49ac4eea 100644
--- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt
+++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt
@@ -14,6 +14,7 @@ import org.oppia.android.app.home.promotedlist.PromotedStoryViewModel
import org.oppia.android.app.model.WrittenTranslationContext
import org.oppia.android.app.player.state.itemviewmodel.DragDropInteractionContentViewModel
import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionContentViewModel
+import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel
import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.databinding.ComingSoonTopicViewBinding
import org.oppia.android.databinding.DragDropInteractionItemsBinding
@@ -21,6 +22,8 @@ import org.oppia.android.databinding.DragDropSingleItemBinding
import org.oppia.android.databinding.ItemSelectionInteractionItemsBinding
import org.oppia.android.databinding.MultipleChoiceInteractionItemsBinding
import org.oppia.android.databinding.PromotedStoryCardBinding
+import org.oppia.android.databinding.SurveyMultipleChoiceItemBinding
+import org.oppia.android.databinding.SurveyNpsItemBinding
import org.oppia.android.domain.translation.TranslationController
import org.oppia.android.util.parser.html.HtmlParser
import javax.inject.Inject
@@ -148,6 +151,50 @@ class ViewBindingShimImpl @Inject constructor(
binding.viewModel = viewModel
}
+ override fun provideMultipleChoiceItemsInflatedView(
+ inflater: LayoutInflater,
+ parent: ViewGroup,
+ attachToParent: Boolean
+ ): View {
+ return SurveyMultipleChoiceItemBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ ).root
+ }
+
+ override fun provideMultipleChoiceOptionViewModel(
+ view: View,
+ viewModel: MultipleChoiceOptionContentViewModel
+ ) {
+ val binding =
+ DataBindingUtil.findBinding(view)!!
+ binding.optionContent = viewModel.optionContent
+ binding.viewModel = viewModel
+ }
+
+ override fun provideNpsItemsInflatedView(
+ inflater: LayoutInflater,
+ parent: ViewGroup,
+ attachToParent: Boolean
+ ): View {
+ return SurveyNpsItemBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ ).root
+ }
+
+ override fun provideNpsItemsViewModel(
+ view: View,
+ viewModel: MultipleChoiceOptionContentViewModel
+ ) {
+ val binding =
+ DataBindingUtil.findBinding(view)!!
+ binding.scoreContent = viewModel.optionContent
+ binding.viewModel = viewModel
+ }
+
override fun provideDragDropSortInteractionInflatedView(
inflater: LayoutInflater,
parent: ViewGroup,
diff --git a/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt
new file mode 100644
index 00000000000..eef21525cba
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt
@@ -0,0 +1,75 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import org.oppia.android.R
+import org.oppia.android.app.fragment.FragmentComponentImpl
+import org.oppia.android.app.fragment.InjectableDialogFragment
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.util.extensions.getProto
+import org.oppia.android.util.extensions.putProto
+import javax.inject.Inject
+
+/** Fragment that displays a dialog for survey exit confirmation. */
+class ExitSurveyConfirmationDialogFragment : InjectableDialogFragment() {
+ @Inject
+ lateinit var exitSurveyConfirmationDialogFragmentPresenter:
+ ExitSurveyConfirmationDialogFragmentPresenter
+
+ companion object {
+ internal const val PROFILE_ID_KEY = "ExitSurveyConfirmationDialogFragment.profile_id"
+
+ /**
+ * Creates a new instance of a DialogFragment to display an exit confirmation in a survey.
+ *
+ * @param profileId the ID of the profile viewing the survey
+ * @return [ExitSurveyConfirmationDialogFragment]: DialogFragment
+ */
+ fun newInstance(
+ profileId: ProfileId
+ ): ExitSurveyConfirmationDialogFragment {
+ return ExitSurveyConfirmationDialogFragment().apply {
+ arguments = Bundle().apply {
+ putProto(PROFILE_ID_KEY, profileId)
+ }
+ }
+ }
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ (fragmentComponent as FragmentComponentImpl).inject(this)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, R.style.ExitSurveyConfirmationDialogStyle)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val args =
+ checkNotNull(
+ arguments
+ ) { "Expected arguments to be passed to ExitSurveyConfirmationDialogFragment" }
+
+ val profileId = args.getProto(PROFILE_ID_KEY, ProfileId.getDefaultInstance())
+
+ return exitSurveyConfirmationDialogFragmentPresenter.handleCreateView(
+ inflater,
+ container,
+ profileId
+ )
+ }
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.setWindowAnimations(R.style.ExitSurveyConfirmationDialogStyle)
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt
new file mode 100644
index 00000000000..13dca266290
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt
@@ -0,0 +1,77 @@
+package org.oppia.android.app.survey
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import org.oppia.android.app.fragment.FragmentScope
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.databinding.SurveyExitConfirmationDialogBinding
+import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.survey.SurveyController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProviders.Companion.toLiveData
+import javax.inject.Inject
+
+const val TAG_EXIT_SURVEY_CONFIRMATION_DIALOG = "EXIT_SURVEY_CONFIRMATION_DIALOG"
+
+/** Presenter for [ExitSurveyConfirmationDialogFragment], sets up bindings from ViewModel. */
+@FragmentScope
+class ExitSurveyConfirmationDialogFragmentPresenter @Inject constructor(
+ private val fragment: Fragment,
+ private val activity: AppCompatActivity,
+ private val surveyController: SurveyController,
+ private val oppiaLogger: OppiaLogger
+) {
+
+ /** Sets up data binding. */
+ fun handleCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ profileId: ProfileId
+ ): View {
+ val binding =
+ SurveyExitConfirmationDialogBinding.inflate(inflater, container, /* attachToRoot= */ false)
+
+ binding.lifecycleOwner = fragment
+
+ binding.continueSurveyButton.setOnClickListener {
+ fragment.parentFragmentManager.beginTransaction()
+ .remove(fragment)
+ .commitNow()
+ }
+
+ binding.exitSurveyButton.setOnClickListener {
+ endSurveyWithCallback { closeSurveyDialogAndActivity() }
+ }
+
+ return binding.root
+ }
+
+ private fun closeSurveyDialogAndActivity() {
+ activity.finish()
+ fragment.parentFragmentManager.beginTransaction()
+ .remove(fragment)
+ .commitNow()
+ }
+
+ private fun endSurveyWithCallback(callback: () -> Unit) {
+ surveyController.stopSurveySession().toLiveData().observe(
+ activity,
+ {
+ when (it) {
+ is AsyncResult.Pending -> oppiaLogger.d("SurveyActivity", "Stopping survey session")
+ is AsyncResult.Failure -> {
+ oppiaLogger.d("SurveyActivity", "Failed to stop the survey session")
+ activity.finish() // Can't recover from the session failing to stop.
+ }
+ is AsyncResult.Success -> {
+ oppiaLogger.d("SurveyActivity", "Stopped the survey session")
+ callback()
+ }
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt b/app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt
new file mode 100644
index 00000000000..d0d1c16b079
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt
@@ -0,0 +1,29 @@
+package org.oppia.android.app.survey
+
+import org.oppia.android.app.model.SurveySelectedAnswer
+
+/** A handler for receiving any change in answer availability to update the 'next' button. */
+interface SelectedAnswerAvailabilityReceiver {
+ /** Called when the input answer availability changes. */
+ fun onPendingAnswerAvailabilityCheck(inputAnswerAvailable: Boolean)
+}
+
+/** A callback that will be called when a user submits an answer. */
+interface SelectedAnswerHandler {
+ /** Return the current selected answer that is ready for submission. */
+ fun getMultipleChoiceAnswer(selectedAnswer: SurveySelectedAnswer)
+
+ /** Return the current text answer that is ready for submission. */
+ fun getFreeFormAnswer(answer: SurveySelectedAnswer)
+}
+
+/** A handler for restoring the previous saved answer for a question on back/forward navigation. */
+interface PreviousAnswerHandler {
+ /** Called when an ephemeral question is loaded to retrieve the previously saved answer. */
+ fun getPreviousAnswer(): SurveySelectedAnswer? {
+ return null
+ }
+
+ /** Called after a previously saved answer is retrieved to update the UI. */
+ fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer)
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt
new file mode 100644
index 00000000000..0a486a2959d
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt
@@ -0,0 +1,65 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import org.oppia.android.app.activity.ActivityComponentImpl
+import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.ScreenName
+import org.oppia.android.app.model.SurveyActivityParams
+import org.oppia.android.util.extensions.getProtoExtra
+import org.oppia.android.util.extensions.putProtoExtra
+import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName
+import javax.inject.Inject
+
+/** The activity for showing a survey. */
+class SurveyActivity : InjectableAutoLocalizedAppCompatActivity() {
+ @Inject lateinit var surveyActivityPresenter: SurveyActivityPresenter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ (activityComponent as ActivityComponentImpl).inject(this)
+
+ val params = intent.extractParams()
+ surveyActivityPresenter.handleOnCreate(
+ this,
+ params.profileId,
+ params.topicId
+ )
+ }
+
+ companion object {
+ private const val PARAMS_KEY = "SurveyActivity.params"
+
+ /**
+ * A convenience function for creating a new [SurveyActivity] intent by prefilling common
+ * params needed by the activity.
+ */
+ fun createSurveyActivityIntent(
+ context: Context,
+ profileId: ProfileId,
+ topicId: String
+ ): Intent {
+ val params = SurveyActivityParams.newBuilder().apply {
+ this.profileId = profileId
+ this.topicId = topicId
+ }.build()
+ return createSurveyActivityIntent(context, params)
+ }
+
+ /** Returns a new [Intent] open a [SurveyActivity] with the specified [params]. */
+ fun createSurveyActivityIntent(
+ context: Context,
+ params: SurveyActivityParams
+ ): Intent {
+ return Intent(context, SurveyActivity::class.java).apply {
+ putProtoExtra(PARAMS_KEY, params)
+ decorateWithScreenName(ScreenName.SURVEY_ACTIVITY)
+ }
+ }
+
+ private fun Intent.extractParams() =
+ getProtoExtra(PARAMS_KEY, SurveyActivityParams.getDefaultInstance())
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt
new file mode 100644
index 00000000000..eea59246707
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt
@@ -0,0 +1,64 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.databinding.DataBindingUtil
+import org.oppia.android.R
+import org.oppia.android.app.activity.ActivityScope
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.databinding.SurveyActivityBinding
+import org.oppia.android.domain.oppialogger.OppiaLogger
+import javax.inject.Inject
+
+private const val TAG_SURVEY_FRAGMENT = "TAG_SURVEY_FRAGMENT"
+
+const val PROFILE_ID_ARGUMENT_KEY = "profile_id"
+const val TOPIC_ID_ARGUMENT_KEY = "topic_id"
+
+/** The Presenter for [SurveyActivity]. */
+@ActivityScope
+class SurveyActivityPresenter @Inject constructor(
+ private val activity: AppCompatActivity,
+ private val oppiaLogger: OppiaLogger
+) {
+ private lateinit var profileId: ProfileId
+ private lateinit var topicId: String
+ private lateinit var context: Context
+
+ private lateinit var binding: SurveyActivityBinding
+
+ fun handleOnCreate(
+ context: Context,
+ profileId: ProfileId,
+ topicId: String
+ ) {
+ binding = DataBindingUtil.setContentView(activity, R.layout.survey_activity)
+ binding.apply {
+ lifecycleOwner = activity
+ }
+
+ this.profileId = profileId
+ this.topicId = topicId
+ this.context = context
+
+ if (getSurveyFragment() == null) {
+ val surveyFragment = SurveyFragment()
+ val args = Bundle()
+ args.putInt(PROFILE_ID_ARGUMENT_KEY, profileId.internalId)
+ args.putString(TOPIC_ID_ARGUMENT_KEY, topicId)
+
+ surveyFragment.arguments = args
+ activity.supportFragmentManager.beginTransaction().add(
+ R.id.survey_fragment_placeholder,
+ surveyFragment, TAG_SURVEY_FRAGMENT
+ ).commitNow()
+ }
+ }
+
+ private fun getSurveyFragment(): SurveyFragment? {
+ return activity.supportFragmentManager.findFragmentByTag(
+ TAG_SURVEY_FRAGMENT
+ ) as? SurveyFragment
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt
new file mode 100644
index 00000000000..aafeea3a082
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt
@@ -0,0 +1,77 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import org.oppia.android.app.fragment.FragmentComponentImpl
+import org.oppia.android.app.fragment.InjectableFragment
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.util.extensions.getStringFromBundle
+import javax.inject.Inject
+
+/** Fragment that represents the current state of a survey. */
+class SurveyFragment :
+ InjectableFragment(),
+ SelectedAnswerAvailabilityReceiver,
+ SelectedAnswerHandler {
+
+ companion object {
+ /**
+ * Creates a new instance of a SurveyFragment.
+ *
+ * @param internalProfileId used by SurveyFragment to record the survey action taken by user
+ * @param topicId used by SurveyFragment for logging purposes
+ * @return a new instance of [SurveyFragment]
+ */
+ fun newInstance(
+ internalProfileId: Int,
+ topicId: String
+ ): SurveyFragment {
+ val surveyFragment = SurveyFragment()
+ val args = Bundle()
+ args.putInt(PROFILE_ID_ARGUMENT_KEY, internalProfileId)
+ args.putString(TOPIC_ID_ARGUMENT_KEY, topicId)
+ surveyFragment.arguments = args
+ return surveyFragment
+ }
+ }
+
+ @Inject
+ lateinit var surveyFragmentPresenter: SurveyFragmentPresenter
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ (fragmentComponent as FragmentComponentImpl).inject(this)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val internalProfileId = arguments!!.getInt(PROFILE_ID_ARGUMENT_KEY, -1)
+ val topicId = arguments!!.getStringFromBundle(TOPIC_ID_ARGUMENT_KEY)!!
+
+ return surveyFragmentPresenter.handleCreateView(
+ inflater,
+ container,
+ internalProfileId,
+ topicId,
+ this
+ )
+ }
+
+ override fun onPendingAnswerAvailabilityCheck(inputAnswerAvailable: Boolean) {
+ surveyFragmentPresenter.updateNextButton(inputAnswerAvailable)
+ }
+
+ override fun getMultipleChoiceAnswer(selectedAnswer: SurveySelectedAnswer) {
+ surveyFragmentPresenter.getPendingAnswer(selectedAnswer)
+ }
+
+ override fun getFreeFormAnswer(answer: SurveySelectedAnswer) {
+ surveyFragmentPresenter.submitFreeFormAnswer(answer)
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt
new file mode 100644
index 00000000000..b88148c7eae
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt
@@ -0,0 +1,335 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.InputMethodManager
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.Toolbar
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.LiveData
+import com.google.android.flexbox.FlexDirection
+import com.google.android.flexbox.FlexboxLayoutManager
+import com.google.android.flexbox.JustifyContent
+import org.oppia.android.app.model.EphemeralSurveyQuestion
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.recyclerview.BindableAdapter
+import org.oppia.android.app.survey.surveyitemviewmodel.FreeFormItemsViewModel
+import org.oppia.android.app.survey.surveyitemviewmodel.MarketFitItemsViewModel
+import org.oppia.android.app.survey.surveyitemviewmodel.NpsItemsViewModel
+import org.oppia.android.app.survey.surveyitemviewmodel.SurveyAnswerItemViewModel
+import org.oppia.android.app.survey.surveyitemviewmodel.UserTypeItemsViewModel
+import org.oppia.android.app.translation.AppLanguageResourceHandler
+import org.oppia.android.databinding.SurveyFragmentBinding
+import org.oppia.android.databinding.SurveyFreeFormLayoutBinding
+import org.oppia.android.databinding.SurveyMarketFitQuestionLayoutBinding
+import org.oppia.android.databinding.SurveyNpsScoreLayoutBinding
+import org.oppia.android.databinding.SurveyUserTypeQuestionLayoutBinding
+import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.survey.SurveyProgressController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProviders.Companion.toLiveData
+import javax.inject.Inject
+
+/** The presenter for [SurveyFragment]. */
+class SurveyFragmentPresenter @Inject constructor(
+ private val activity: AppCompatActivity,
+ private val fragment: Fragment,
+ private val oppiaLogger: OppiaLogger,
+ private val surveyProgressController: SurveyProgressController,
+ private val surveyViewModel: SurveyViewModel,
+ private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory,
+ private val resourceHandler: AppLanguageResourceHandler
+) {
+ private val ephemeralQuestionLiveData: LiveData> by lazy {
+ surveyProgressController.getCurrentQuestion().toLiveData()
+ }
+
+ private lateinit var profileId: ProfileId
+ private lateinit var topicId: String
+ private lateinit var binding: SurveyFragmentBinding
+ private lateinit var surveyToolbar: Toolbar
+ private lateinit var answerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver
+ private lateinit var answerHandler: SelectedAnswerHandler
+ private lateinit var questionSelectedAnswer: SurveySelectedAnswer
+
+ /** Sets up data binding. */
+ fun handleCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ internalProfileId: Int,
+ topicId: String,
+ fragment: SurveyFragment
+ ): View? {
+ profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build()
+ this.topicId = topicId
+ this.answerAvailabilityReceiver = fragment
+ this.answerHandler = fragment
+
+ binding = SurveyFragmentBinding.inflate(
+ inflater,
+ container,
+ /* attachToRoot= */ false
+ )
+ binding.apply {
+ lifecycleOwner = fragment
+ viewModel = surveyViewModel
+ }
+
+ surveyToolbar = binding.surveyToolbar
+ activity.setSupportActionBar(surveyToolbar)
+ surveyToolbar.setNavigationOnClickListener {
+ val dialogFragment = ExitSurveyConfirmationDialogFragment.newInstance(profileId)
+ dialogFragment.showNow(fragment.childFragmentManager, TAG_EXIT_SURVEY_CONFIRMATION_DIALOG)
+ }
+
+ binding.surveyAnswersRecyclerView.apply {
+ adapter = createRecyclerViewAdapter()
+ }
+
+ binding.surveyNextButton.setOnClickListener {
+ if (::questionSelectedAnswer.isInitialized) {
+ surveyProgressController.submitAnswer(questionSelectedAnswer)
+ }
+ }
+
+ binding.surveyPreviousButton.setOnClickListener {
+ surveyProgressController.moveToPreviousQuestion()
+ }
+
+ subscribeToCurrentQuestion()
+
+ return binding.root
+ }
+
+ private fun createRecyclerViewAdapter(): BindableAdapter {
+ return multiTypeBuilderFactory
+ .create { viewModel ->
+ when (viewModel) {
+ is MarketFitItemsViewModel -> {
+ SurveyAnswerItemViewModel.ViewType.MARKET_FIT_OPTIONS
+ }
+ is UserTypeItemsViewModel -> {
+ SurveyAnswerItemViewModel.ViewType.USER_TYPE_OPTIONS
+ }
+ is NpsItemsViewModel -> {
+ SurveyAnswerItemViewModel.ViewType.NPS_OPTIONS
+ }
+ is FreeFormItemsViewModel -> {
+ SurveyAnswerItemViewModel.ViewType.FREE_FORM_ANSWER
+ }
+ else -> {
+ throw IllegalStateException("Invalid ViewType")
+ }
+ }
+ }
+ .registerViewDataBinder(
+ viewType = SurveyAnswerItemViewModel.ViewType.USER_TYPE_OPTIONS,
+ inflateDataBinding = SurveyUserTypeQuestionLayoutBinding::inflate,
+ setViewModel = SurveyUserTypeQuestionLayoutBinding::setViewModel,
+ transformViewModel = { it as UserTypeItemsViewModel }
+ )
+ .registerViewDataBinder(
+ viewType = SurveyAnswerItemViewModel.ViewType.MARKET_FIT_OPTIONS,
+ inflateDataBinding = SurveyMarketFitQuestionLayoutBinding::inflate,
+ setViewModel = SurveyMarketFitQuestionLayoutBinding::setViewModel,
+ transformViewModel = { it as MarketFitItemsViewModel }
+ )
+ .registerViewBinder(
+ viewType = SurveyAnswerItemViewModel.ViewType.NPS_OPTIONS,
+ inflateView = { parent ->
+ SurveyNpsScoreLayoutBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ /* attachToParent= */ false
+ ).root
+ },
+ bindView = { view, viewModel ->
+ val binding = DataBindingUtil.findBinding(view)!!
+ val npsViewModel = viewModel as NpsItemsViewModel
+ binding.viewModel = npsViewModel
+
+ val flexLayoutManager = FlexboxLayoutManager(activity)
+ flexLayoutManager.flexDirection = FlexDirection.ROW
+ flexLayoutManager.justifyContent = JustifyContent.CENTER
+
+ binding.surveyNpsButtonsContainer.layoutManager = flexLayoutManager
+ }
+ )
+ .registerViewDataBinder(
+ viewType = SurveyAnswerItemViewModel.ViewType.FREE_FORM_ANSWER,
+ inflateDataBinding = SurveyFreeFormLayoutBinding::inflate,
+ setViewModel = SurveyFreeFormLayoutBinding::setViewModel,
+ transformViewModel = { it as FreeFormItemsViewModel }
+ )
+ .build()
+ }
+
+ private fun subscribeToCurrentQuestion() {
+ ephemeralQuestionLiveData.observe(
+ fragment,
+ {
+ processEphemeralQuestionResult(it)
+ }
+ )
+ }
+
+ private fun processEphemeralQuestionResult(result: AsyncResult) {
+ when (result) {
+ is AsyncResult.Failure -> {
+ oppiaLogger.e(
+ "SurveyFragment", "Failed to retrieve ephemeral question", result.error
+ )
+ }
+ is AsyncResult.Pending -> {} // Display nothing until a valid result is available.
+ is AsyncResult.Success -> processEphemeralQuestion(result.value)
+ }
+ }
+
+ private fun processEphemeralQuestion(ephemeralQuestion: EphemeralSurveyQuestion) {
+ val questionName = ephemeralQuestion.question.questionName
+ surveyViewModel.itemList.clear()
+ when (questionName) {
+ SurveyQuestionName.USER_TYPE -> surveyViewModel.itemList.add(
+ UserTypeItemsViewModel(
+ resourceHandler,
+ answerAvailabilityReceiver,
+ answerHandler
+ )
+ )
+ SurveyQuestionName.MARKET_FIT -> surveyViewModel.itemList.add(
+ MarketFitItemsViewModel(
+ resourceHandler,
+ answerAvailabilityReceiver,
+ answerHandler
+ )
+ )
+ SurveyQuestionName.NPS -> surveyViewModel.itemList.add(
+ NpsItemsViewModel(
+ answerAvailabilityReceiver,
+ answerHandler
+ )
+ )
+ SurveyQuestionName.PROMOTER_FEEDBACK -> surveyViewModel.itemList.add(
+ FreeFormItemsViewModel(
+ answerAvailabilityReceiver,
+ questionName,
+ answerHandler
+ )
+ )
+ SurveyQuestionName.PASSIVE_FEEDBACK -> surveyViewModel.itemList.add(
+ FreeFormItemsViewModel(
+ answerAvailabilityReceiver,
+ questionName,
+ answerHandler
+ )
+ )
+ SurveyQuestionName.DETRACTOR_FEEDBACK -> surveyViewModel.itemList.add(
+ FreeFormItemsViewModel(
+ answerAvailabilityReceiver,
+ questionName,
+ answerHandler
+ )
+ )
+ else -> {}
+ }
+ updateProgress(ephemeralQuestion.currentQuestionIndex, ephemeralQuestion.totalQuestionCount)
+ updateQuestionText(questionName)
+
+ if (ephemeralQuestion.selectedAnswer != SurveySelectedAnswer.getDefaultInstance()) {
+ surveyViewModel.retrievePreviousAnswer(
+ ephemeralQuestion.selectedAnswer,
+ ::getPreviousAnswerHandler
+ )
+ }
+ }
+
+ private fun getPreviousAnswerHandler(
+ itemList: List
+ ): PreviousAnswerHandler? {
+ return itemList.findLast { it is PreviousAnswerHandler } as? PreviousAnswerHandler
+ }
+
+ private fun updateProgress(currentQuestionIndex: Int, questionCount: Int) {
+ surveyViewModel.updateQuestionProgress(
+ progressPercentage = (((currentQuestionIndex + 1) / questionCount.toDouble()) * 100).toInt()
+ )
+ toggleNavigationButtonVisibility(currentQuestionIndex, questionCount)
+ }
+
+ private fun updateQuestionText(questionName: SurveyQuestionName) {
+ surveyViewModel.updateQuestionText(questionName)
+ }
+
+ /**
+ * Updates whether the 'next' button should be active based on whether an answer to the current
+ * question has been provided.
+ */
+ fun updateNextButton(inputAnswerAvailable: Boolean) {
+ surveyViewModel.setCanMoveToNextQuestion(inputAnswerAvailable)
+ }
+
+ /** Retrieves the answer that was selected by the user for a question. */
+ fun getPendingAnswer(answer: SurveySelectedAnswer) {
+ this.questionSelectedAnswer = answer
+ }
+
+ /**
+ * Retrieves and submits the free text answer that was provided by the user, then navigates to the
+ * final screen.
+ */
+ fun submitFreeFormAnswer(answer: SurveySelectedAnswer) {
+ hideKeyboard()
+ surveyProgressController.submitAnswer(answer).toLiveData().observe(
+ fragment,
+ { result ->
+ when (result) {
+ is AsyncResult.Failure -> {
+ oppiaLogger.e(
+ "SurveyFragment", "Failed to submit free form answer", result.error
+ )
+ }
+ is AsyncResult.Pending -> {} // Do nothing until a valid result is available.
+ is AsyncResult.Success -> {
+ val dialogFragment = SurveyOutroDialogFragment.newInstance()
+ val transaction = activity.supportFragmentManager.beginTransaction()
+ transaction
+ .add(dialogFragment, TAG_SURVEY_OUTRO_DIALOG)
+ .commit()
+ activity.supportFragmentManager.executePendingTransactions()
+ }
+ }
+ }
+ )
+ }
+
+ private fun toggleNavigationButtonVisibility(questionIndex: Int, questionCount: Int) {
+ when (questionIndex) {
+ 0 -> {
+ binding.surveyNextButton.visibility = View.VISIBLE
+ binding.surveyPreviousButton.visibility = View.GONE
+ }
+ (questionCount - 1) -> {
+ binding.surveyNextButton.visibility = View.GONE
+ binding.surveyPreviousButton.visibility = View.VISIBLE
+ }
+ else -> {
+ binding.surveyNextButton.visibility = View.VISIBLE
+ binding.surveyPreviousButton.visibility = View.VISIBLE
+ }
+ }
+ }
+
+ private fun hideKeyboard() {
+ val inputManager: InputMethodManager =
+ activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ inputManager.hideSoftInputFromWindow(
+ fragment.view!!.windowToken,
+ InputMethodManager.SHOW_FORCED
+ )
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt
new file mode 100644
index 00000000000..9823860f2a6
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt
@@ -0,0 +1,79 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import androidx.databinding.ObservableList
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.recyclerview.widget.RecyclerView
+import org.oppia.android.app.recyclerview.BindableAdapter
+import org.oppia.android.app.shim.ViewBindingShim
+import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel
+import org.oppia.android.app.view.ViewComponentFactory
+import org.oppia.android.app.view.ViewComponentImpl
+import javax.inject.Inject
+
+/**
+ * A custom [RecyclerView] for displaying a variable list of items that may be selected by a user as
+ * part of the multiple choice option selection.
+ */
+class SurveyMultipleChoiceOptionView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : RecyclerView(context, attrs, defStyleAttr) {
+ @Inject
+ lateinit var bindingInterface: ViewBindingShim
+
+ @Inject
+ lateinit var singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory
+ private lateinit var dataList: ObservableList
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory
+ val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl
+ viewComponent.inject(this)
+ maybeInitializeAdapter()
+ }
+
+ /**
+ * Sets the view's RecyclerView [MultipleChoiceOptionContentViewModel] data list.
+ *
+ * Note that this needs to be used instead of the generic RecyclerView 'data' binding adapter
+ * since this one takes into account initialization order with other binding properties.
+ */
+ fun setSelectionData(dataList: ObservableList) {
+ this.dataList = dataList
+ maybeInitializeAdapter()
+ }
+
+ private fun maybeInitializeAdapter() {
+ if (::singleTypeBuilderFactory.isInitialized &&
+ ::dataList.isInitialized
+ ) {
+ adapter = createAdapter().also { it.setData(dataList) }
+ }
+ }
+
+ private fun createAdapter(): BindableAdapter {
+ return singleTypeBuilderFactory.create()
+ .registerViewBinder(
+ inflateView = { parent ->
+ bindingInterface.provideMultipleChoiceItemsInflatedView(
+ LayoutInflater.from(parent.context),
+ parent,
+ /* attachToParent= */ false
+ )
+ },
+ bindView = { view, viewModel ->
+ bindingInterface.provideMultipleChoiceOptionViewModel(
+ view,
+ viewModel
+ )
+ }
+ )
+ .build()
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt
new file mode 100644
index 00000000000..85625f9a071
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt
@@ -0,0 +1,79 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import androidx.databinding.ObservableList
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.recyclerview.widget.RecyclerView
+import org.oppia.android.app.recyclerview.BindableAdapter
+import org.oppia.android.app.shim.ViewBindingShim
+import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel
+import org.oppia.android.app.view.ViewComponentFactory
+import org.oppia.android.app.view.ViewComponentImpl
+import javax.inject.Inject
+
+/**
+ * A custom [RecyclerView] for displaying a list of radio buttons that may be selected by a user as
+ * a score for the nps score survey question.
+ */
+class SurveyNpsItemOptionView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : RecyclerView(context, attrs, defStyleAttr) {
+ @Inject
+ lateinit var bindingInterface: ViewBindingShim
+
+ @Inject
+ lateinit var singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory
+ private lateinit var dataList: ObservableList
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory
+ val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl
+ viewComponent.inject(this)
+ maybeInitializeAdapter()
+ }
+
+ /**
+ * Sets the view's RecyclerView [MultipleChoiceOptionContentViewModel] data list.
+ *
+ * Note that this needs to be used instead of the generic RecyclerView 'data' binding adapter
+ * since this one takes into account initialization order with other binding properties.
+ */
+ fun setSelectionData(dataList: ObservableList) {
+ this.dataList = dataList
+ maybeInitializeAdapter()
+ }
+
+ private fun maybeInitializeAdapter() {
+ if (::singleTypeBuilderFactory.isInitialized &&
+ ::dataList.isInitialized
+ ) {
+ adapter = createAdapter().also { it.setData(dataList) }
+ }
+ }
+
+ private fun createAdapter(): BindableAdapter {
+ return singleTypeBuilderFactory.create()
+ .registerViewBinder(
+ inflateView = { parent ->
+ bindingInterface.provideNpsItemsInflatedView(
+ LayoutInflater.from(parent.context),
+ parent,
+ /* attachToParent= */ false
+ )
+ },
+ bindView = { view, viewModel ->
+ bindingInterface.provideNpsItemsViewModel(
+ view,
+ viewModel
+ )
+ }
+ )
+ .build()
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt
new file mode 100644
index 00000000000..b988415c547
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt
@@ -0,0 +1,54 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import org.oppia.android.R
+import org.oppia.android.app.fragment.FragmentComponentImpl
+import org.oppia.android.app.fragment.InjectableDialogFragment
+import javax.inject.Inject
+
+/** Fragment that displays a fullscreen dialog for survey on-boarding. */
+class SurveyOutroDialogFragment : InjectableDialogFragment() {
+ @Inject
+ lateinit var surveyOutroDialogFragmentPresenter: SurveyOutroDialogFragmentPresenter
+
+ companion object {
+ /**
+ * Creates a new instance of a DialogFragment to display the survey thank you message.
+ *
+ * @return [SurveyOutroDialogFragment]: DialogFragment
+ */
+ fun newInstance(): SurveyOutroDialogFragment {
+ return SurveyOutroDialogFragment()
+ }
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ (fragmentComponent as FragmentComponentImpl).inject(this)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, R.style.SurveyOnboardingDialogStyle)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ return surveyOutroDialogFragmentPresenter.handleCreateView(
+ inflater,
+ container
+ )
+ }
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.setWindowAnimations(R.style.SurveyOnboardingDialogStyle)
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt
new file mode 100644
index 00000000000..6ad91d5dde5
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt
@@ -0,0 +1,74 @@
+package org.oppia.android.app.survey
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import org.oppia.android.R
+import org.oppia.android.app.fragment.FragmentScope
+import org.oppia.android.app.translation.AppLanguageResourceHandler
+import org.oppia.android.databinding.SurveyOutroDialogFragmentBinding
+import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.survey.SurveyController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProviders.Companion.toLiveData
+import javax.inject.Inject
+
+const val TAG_SURVEY_OUTRO_DIALOG = "SURVEY_OUTRO_DIALOG"
+
+/** Presenter for [SurveyWelcomeDialogFragment], sets up bindings. */
+@FragmentScope
+class SurveyOutroDialogFragmentPresenter @Inject constructor(
+ private val activity: AppCompatActivity,
+ private val fragment: Fragment,
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val surveyController: SurveyController,
+ private val oppiaLogger: OppiaLogger
+) {
+ /** Sets up data binding. */
+ fun handleCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ ): View {
+ val binding =
+ SurveyOutroDialogFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
+
+ binding.lifecycleOwner = fragment
+
+ val appName = resourceHandler.getStringInLocale(R.string.app_name)
+ binding.surveyOnboardingText.text = resourceHandler.getStringInLocaleWithWrapping(
+ R.string.survey_thank_you_message_text, appName
+ )
+
+ binding.finishSurveyButton.setOnClickListener {
+ endSurveyWithCallback { closeSurveyDialogAndActivity() }
+ }
+
+ return binding.root
+ }
+
+ private fun closeSurveyDialogAndActivity() {
+ activity.finish()
+ activity.supportFragmentManager.beginTransaction().remove(fragment).commitNow()
+ }
+
+ private fun endSurveyWithCallback(callback: () -> Unit) {
+ surveyController.stopSurveySession().toLiveData().observe(
+ activity,
+ {
+ when (it) {
+ is AsyncResult.Pending -> oppiaLogger.d("SurveyActivity", "Stopping survey session")
+ is AsyncResult.Failure -> {
+ oppiaLogger.d("SurveyActivity", "Failed to stop the survey session")
+ activity.finish() // Can't recover from the session failing to stop.
+ }
+ is AsyncResult.Success -> {
+ oppiaLogger.d("SurveyActivity", "Stopped the survey session")
+ callback()
+ }
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt
new file mode 100644
index 00000000000..0979177affe
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt
@@ -0,0 +1,97 @@
+package org.oppia.android.app.survey
+
+import androidx.databinding.ObservableField
+import androidx.databinding.ObservableList
+import org.oppia.android.R
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.survey.surveyitemviewmodel.SurveyAnswerItemViewModel
+import org.oppia.android.app.translation.AppLanguageResourceHandler
+import org.oppia.android.app.viewmodel.ObservableArrayList
+import org.oppia.android.app.viewmodel.ObservableViewModel
+import javax.inject.Inject
+
+class SurveyViewModel @Inject constructor(
+ private val resourceHandler: AppLanguageResourceHandler
+) : ObservableViewModel() {
+ val itemList: ObservableList = ObservableArrayList()
+ val itemIndex = ObservableField()
+ private val canMoveToNextQuestion = ObservableField(false)
+
+ val progressPercentage = ObservableField(0)
+
+ val questionProgressText: ObservableField =
+ ObservableField("$DEFAULT_QUESTION_PROGRESS%")
+
+ val questionText: ObservableField =
+ ObservableField(DEFAULT_QUESTION)
+
+ fun updateQuestionProgress(
+ progressPercentage: Int
+ ) {
+ this.progressPercentage.set(progressPercentage)
+ questionProgressText.set("$progressPercentage%")
+ }
+
+ fun updateQuestionText(questionName: SurveyQuestionName) {
+ questionText.set(getQuestionText(questionName))
+ setCanMoveToNextQuestion(false)
+ }
+
+ fun setCanMoveToNextQuestion(canMoveToNext: Boolean) =
+ this.canMoveToNextQuestion.set(canMoveToNext)
+
+ fun getCanMoveToNextQuestion(): ObservableField = canMoveToNextQuestion
+
+ fun retrievePreviousAnswer(
+ previousAnswer: SurveySelectedAnswer,
+ retrieveAnswerHandler: (List) -> PreviousAnswerHandler?
+ ) {
+ restorePreviousAnswer(
+ previousAnswer,
+ retrieveAnswerHandler(
+ itemList
+ )
+ )
+ }
+
+ private fun restorePreviousAnswer(
+ previousAnswer: SurveySelectedAnswer,
+ answerHandler: PreviousAnswerHandler?
+ ) {
+ answerHandler?.restorePreviousAnswer(previousAnswer)
+ }
+
+ private fun getQuestionText(
+ questionName: SurveyQuestionName
+ ): String {
+ val appName = resourceHandler.getStringInLocale(R.string.app_name)
+ return when (questionName) {
+ SurveyQuestionName.USER_TYPE -> resourceHandler.getStringInLocale(
+ R.string.user_type_question
+ )
+ SurveyQuestionName.MARKET_FIT -> resourceHandler.getStringInLocaleWithWrapping(
+ R.string.market_fit_question, appName
+ )
+ SurveyQuestionName.NPS -> resourceHandler.getStringInLocaleWithWrapping(
+ R.string.nps_score_question, appName
+ )
+ SurveyQuestionName.PROMOTER_FEEDBACK -> resourceHandler.getStringInLocaleWithWrapping(
+ R.string.nps_promoter_feedback_question, appName
+ )
+ SurveyQuestionName.PASSIVE_FEEDBACK -> resourceHandler.getStringInLocaleWithWrapping(
+ R.string.nps_passive_feedback_question
+ )
+ SurveyQuestionName.DETRACTOR_FEEDBACK -> resourceHandler.getStringInLocaleWithWrapping(
+ R.string.nps_detractor_feedback_question
+ )
+ SurveyQuestionName.UNRECOGNIZED, SurveyQuestionName.QUESTION_NAME_UNSPECIFIED ->
+ DEFAULT_QUESTION
+ }
+ }
+
+ private companion object {
+ private const val DEFAULT_QUESTION_PROGRESS = 25
+ private const val DEFAULT_QUESTION = "some_question"
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt
new file mode 100644
index 00000000000..528f3b7d9b4
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt
@@ -0,0 +1,100 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import org.oppia.android.R
+import org.oppia.android.app.fragment.FragmentComponentImpl
+import org.oppia.android.app.fragment.InjectableDialogFragment
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.util.extensions.getProto
+import org.oppia.android.util.extensions.getStringFromBundle
+import org.oppia.android.util.extensions.putProto
+import javax.inject.Inject
+
+/** Fragment that displays a fullscreen dialog for survey on-boarding. */
+class SurveyWelcomeDialogFragment : InjectableDialogFragment() {
+ @Inject
+ lateinit var surveyWelcomeDialogFragmentPresenter: SurveyWelcomeDialogFragmentPresenter
+
+ companion object {
+ internal const val PROFILE_ID_KEY = "SurveyWelcomeDialogFragment.profile_id"
+ internal const val TOPIC_ID_KEY = "SurveyWelcomeDialogFragment.topic_id"
+ internal const val MANDATORY_QUESTION_NAMES_KEY = "SurveyWelcomeDialogFragment.question_names"
+
+ /**
+ * Creates a new instance of a DialogFragment to display the survey on-boarding message.
+ *
+ * @param profileId the ID of the profile viewing the survey prompt
+ * @return [SurveyWelcomeDialogFragment]: DialogFragment
+ */
+ fun newInstance(
+ profileId: ProfileId,
+ topicId: String,
+ mandatoryQuestionNames: List,
+ ): SurveyWelcomeDialogFragment {
+ return SurveyWelcomeDialogFragment().apply {
+ arguments = Bundle().apply {
+ putProto(PROFILE_ID_KEY, profileId)
+ putString(TOPIC_ID_KEY, topicId)
+ putQuestions(MANDATORY_QUESTION_NAMES_KEY, extractQuestions(mandatoryQuestionNames))
+ }
+ }
+ }
+ }
+
+ private fun Bundle.putQuestions(name: String, nameList: IntArray) {
+ putSerializable(name, nameList)
+ }
+
+ private fun extractQuestions(questionNames: List): IntArray {
+ return questionNames.map { questionName -> questionName.number }.toIntArray()
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ (fragmentComponent as FragmentComponentImpl).inject(this)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, R.style.SurveyOnboardingDialogStyle)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val args =
+ checkNotNull(
+ arguments
+ ) { "Expected arguments to be passed to SurveyWelcomeDialogFragment" }
+
+ val profileId = args.getProto(PROFILE_ID_KEY, ProfileId.getDefaultInstance())
+ val topicId = args.getStringFromBundle(TOPIC_ID_KEY)!!
+ val surveyQuestions = args.getQuestions()
+
+ return surveyWelcomeDialogFragmentPresenter.handleCreateView(
+ inflater,
+ container,
+ profileId,
+ topicId,
+ surveyQuestions
+ )
+ }
+
+ private fun Bundle.getQuestions(): List {
+ val questionArgs = getIntArray(MANDATORY_QUESTION_NAMES_KEY)
+ return questionArgs?.map { number -> SurveyQuestionName.forNumber(number) }
+ ?: listOf()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.setWindowAnimations(R.style.SurveyOnboardingDialogStyle)
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt
new file mode 100644
index 00000000000..92212cd6dab
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt
@@ -0,0 +1,87 @@
+package org.oppia.android.app.survey
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import org.oppia.android.app.fragment.FragmentScope
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.databinding.SurveyWelcomeDialogFragmentBinding
+import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.survey.SurveyController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProviders.Companion.toLiveData
+import javax.inject.Inject
+
+const val TAG_SURVEY_WELCOME_DIALOG = "SURVEY_WELCOME_DIALOG"
+
+/** Presenter for [SurveyWelcomeDialogFragment], sets up bindings. */
+@FragmentScope
+class SurveyWelcomeDialogFragmentPresenter @Inject constructor(
+ private val activity: AppCompatActivity,
+ private val fragment: Fragment,
+ private val surveyController: SurveyController,
+ private val oppiaLogger: OppiaLogger
+) {
+ /** Sets up data binding. */
+ fun handleCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ profileId: ProfileId,
+ topicId: String,
+ questionNames: List,
+ ): View {
+ val binding =
+ SurveyWelcomeDialogFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
+
+ binding.lifecycleOwner = fragment
+
+ binding.beginSurveyButton.setOnClickListener {
+ startSurveySession(profileId, topicId, questionNames)
+ }
+
+ binding.maybeLaterButton.setOnClickListener {
+ activity.supportFragmentManager.beginTransaction()
+ .remove(fragment)
+ .commitNow()
+ }
+
+ return binding.root
+ }
+
+ private fun startSurveySession(
+ profileId: ProfileId,
+ topicId: String,
+ questions: List
+ ) {
+ val startDataProvider = surveyController.startSurveySession(questions)
+ startDataProvider.toLiveData().observe(
+ activity,
+ {
+ when (it) {
+ is AsyncResult.Pending ->
+ oppiaLogger.d("SurveyWelcomeDialogFragment", "Starting a survey session")
+ is AsyncResult.Failure -> {
+ oppiaLogger.e(
+ "SurveyWelcomeDialogFragment",
+ "Failed to start a survey session",
+ it.error
+ )
+ activity.finish() // Can't recover from the session failing to start.
+ }
+ is AsyncResult.Success -> {
+ oppiaLogger.d("SurveyWelcomeDialogFragment", "Successfully started a survey session")
+ val intent =
+ SurveyActivity.createSurveyActivityIntent(activity, profileId, topicId)
+ fragment.startActivity(intent)
+ activity.finish()
+ val transaction = activity.supportFragmentManager.beginTransaction()
+ transaction.remove(fragment).commitAllowingStateLoss()
+ }
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt
new file mode 100644
index 00000000000..752fd524f5a
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt
@@ -0,0 +1,60 @@
+package org.oppia.android.app.survey.surveyitemviewmodel
+
+import android.text.Editable
+import android.text.TextWatcher
+import androidx.databinding.Observable
+import androidx.databinding.ObservableField
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver
+import org.oppia.android.app.survey.SelectedAnswerHandler
+import javax.inject.Inject
+
+class FreeFormItemsViewModel @Inject constructor(
+ private val answerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver,
+ private val questionName: SurveyQuestionName,
+ private val answerHandler: SelectedAnswerHandler
+) : SurveyAnswerItemViewModel(ViewType.FREE_FORM_ANSWER) {
+ var answerText: CharSequence = ""
+ val isAnswerAvailable = ObservableField(false)
+
+ init {
+ val callback: Observable.OnPropertyChangedCallback =
+ object : Observable.OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ answerAvailabilityReceiver.onPendingAnswerAvailabilityCheck(
+ answerText.isNotEmpty()
+ )
+ }
+ }
+ isAnswerAvailable.addOnPropertyChangedCallback(callback)
+ }
+
+ fun getAnswerTextWatcher(): TextWatcher {
+ return object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
+ }
+
+ override fun onTextChanged(answer: CharSequence, start: Int, before: Int, count: Int) {
+ answerText = answer.toString().trim()
+ val isAnswerTextAvailable = answerText.isNotEmpty()
+ if (isAnswerTextAvailable != isAnswerAvailable.get()) {
+ isAnswerAvailable.set(isAnswerTextAvailable)
+ }
+ }
+
+ override fun afterTextChanged(s: Editable) {
+ }
+ }
+ }
+
+ fun handleSubmitButtonClicked() {
+ if (answerText.isNotEmpty()) {
+ val answer = SurveySelectedAnswer.newBuilder()
+ .setQuestionName(questionName)
+ .setFreeFormAnswer(answerText.toString())
+ .build()
+ answerHandler.getFreeFormAnswer(answer)
+ }
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt
new file mode 100644
index 00000000000..e2b7fe43a5e
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt
@@ -0,0 +1,153 @@
+package org.oppia.android.app.survey.surveyitemviewmodel
+
+import androidx.databinding.Observable
+import androidx.databinding.ObservableArrayList
+import androidx.databinding.ObservableField
+import androidx.databinding.ObservableList
+import org.oppia.android.R
+import org.oppia.android.app.model.MarketFitAnswer
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.survey.PreviousAnswerHandler
+import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver
+import org.oppia.android.app.survey.SelectedAnswerHandler
+import org.oppia.android.app.translation.AppLanguageResourceHandler
+import javax.inject.Inject
+
+/** [SurveyAnswerItemViewModel] for the market fit question options. */
+class MarketFitItemsViewModel @Inject constructor(
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val selectedAnswerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver,
+ private val answerHandler: SelectedAnswerHandler
+) : SurveyAnswerItemViewModel(ViewType.MARKET_FIT_OPTIONS), PreviousAnswerHandler {
+ val optionItems: ObservableList = getMarketFitOptions()
+
+ private val selectedItems: MutableList = mutableListOf()
+
+ override fun updateSelection(itemIndex: Int): Boolean {
+ optionItems.forEach { item -> item.isAnswerSelected.set(false) }
+ if (!selectedItems.contains(itemIndex)) {
+ selectedItems.clear()
+ selectedItems += itemIndex
+ } else {
+ selectedItems.clear()
+ }
+
+ updateIsAnswerAvailable()
+
+ if (selectedItems.isNotEmpty()) {
+ getPendingAnswer(itemIndex)
+ }
+
+ return selectedItems.isNotEmpty()
+ }
+
+ val isAnswerAvailable = ObservableField(false)
+
+ init {
+ val callback: Observable.OnPropertyChangedCallback =
+ object : Observable.OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ selectedAnswerAvailabilityReceiver.onPendingAnswerAvailabilityCheck(
+ selectedItems.isNotEmpty()
+ )
+ }
+ }
+ isAnswerAvailable.addOnPropertyChangedCallback(callback)
+ }
+
+ private fun updateIsAnswerAvailable() {
+ val selectedItemListWasEmpty = isAnswerAvailable.get()
+ if (selectedItems.isNotEmpty() != selectedItemListWasEmpty) {
+ isAnswerAvailable.set(selectedItems.isNotEmpty())
+ }
+ }
+
+ private fun getPendingAnswer(itemIndex: Int) {
+ val typeCase = itemIndex + 1
+ val answerValue = MarketFitAnswer.forNumber(typeCase)
+ val answer = SurveySelectedAnswer.newBuilder()
+ .setQuestionName(SurveyQuestionName.MARKET_FIT)
+ .setMarketFit(answerValue)
+ .build()
+ answerHandler.getMultipleChoiceAnswer(answer)
+ }
+
+ override fun getPreviousAnswer(): SurveySelectedAnswer {
+ return SurveySelectedAnswer.getDefaultInstance()
+ }
+
+ override fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) {
+ // Index 0 corresponds to ANSWER_UNSPECIFIED which is not a valid option so it's filtered out.
+ // Valid enum type numbers start from 1 while list item indices start from 0, hence the minus(1)
+ // to get the correct index to update. Notice that for [getPendingAnswer] we increment the index
+ // to get the correct typeCase to save.
+ val previousSelection = previousAnswer.marketFit.number.takeIf { it != 0 }?.minus(1)
+
+ selectedItems.apply {
+ clear()
+ previousSelection?.let { optionIndex ->
+ add(optionIndex)
+ updateIsAnswerAvailable()
+ getPendingAnswer(optionIndex)
+ optionItems[optionIndex].isAnswerSelected.set(true)
+ }
+ }
+ }
+
+ private fun getMarketFitOptions(): ObservableList {
+ val appName = resourceHandler.getStringInLocale(R.string.app_name)
+ val observableList = ObservableArrayList()
+ observableList += MarketFitAnswer.values()
+ .filter { it.isValid() }
+ .mapIndexed { index, marketFitAnswer ->
+ when (marketFitAnswer) {
+ MarketFitAnswer.VERY_DISAPPOINTED -> MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.market_fit_answer_very_disappointed
+ ),
+ index,
+ this
+ )
+
+ MarketFitAnswer.DISAPPOINTED -> MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.market_fit_answer_somewhat_disappointed
+ ),
+ index,
+ this
+ )
+
+ MarketFitAnswer.NOT_DISAPPOINTED -> MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.market_fit_answer_not_disappointed
+ ),
+ index,
+ this
+ )
+
+ MarketFitAnswer.NOT_APPLICABLE_WONT_USE_OPPIA_ANYMORE ->
+ MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocaleWithWrapping(
+ R.string.market_fit_answer_wont_use_oppia,
+ appName
+ ),
+ index,
+ this
+ )
+ else -> throw IllegalStateException("Invalid MarketFitAnswer")
+ }
+ }
+ return observableList
+ }
+
+ companion object {
+ /** Returns whether a [MarketFitAnswer] is valid. */
+ fun MarketFitAnswer.isValid(): Boolean {
+ return when (this) {
+ MarketFitAnswer.UNRECOGNIZED, MarketFitAnswer.MARKET_FIT_ANSWER_UNSPECIFIED -> false
+ else -> true
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt
new file mode 100644
index 00000000000..be29f812ce0
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt
@@ -0,0 +1,22 @@
+package org.oppia.android.app.survey.surveyitemviewmodel
+
+import androidx.databinding.ObservableBoolean
+import org.oppia.android.app.viewmodel.ObservableViewModel
+
+/** [ObservableViewModel] for MultipleChoiceInput values. */
+class MultipleChoiceOptionContentViewModel(
+ val optionContent: String,
+ val itemIndex: Int,
+ private val optionsViewModel: SurveyAnswerItemViewModel
+) : ObservableViewModel() {
+ var isAnswerSelected = ObservableBoolean()
+
+ fun handleItemClicked() {
+ val isCurrentlySelected = isAnswerSelected.get()
+ val shouldNowBeSelected =
+ optionsViewModel.updateSelection(itemIndex)
+ if (isCurrentlySelected != shouldNowBeSelected) {
+ isAnswerSelected.set(shouldNowBeSelected)
+ }
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt
new file mode 100644
index 00000000000..1469228b49a
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt
@@ -0,0 +1,99 @@
+package org.oppia.android.app.survey.surveyitemviewmodel
+
+import androidx.databinding.Observable
+import androidx.databinding.ObservableField
+import androidx.databinding.ObservableList
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.survey.PreviousAnswerHandler
+import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver
+import org.oppia.android.app.survey.SelectedAnswerHandler
+import org.oppia.android.app.viewmodel.ObservableArrayList
+import javax.inject.Inject
+
+class NpsItemsViewModel @Inject constructor(
+ private val selectedAnswerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver,
+ private val answerHandler: SelectedAnswerHandler
+) : SurveyAnswerItemViewModel(ViewType.NPS_OPTIONS), PreviousAnswerHandler {
+ val optionItems: ObservableList = getNpsOptions()
+
+ private val selectedItems: MutableList = mutableListOf()
+
+ override fun updateSelection(itemIndex: Int): Boolean {
+ optionItems.forEach { item -> item.isAnswerSelected.set(false) }
+ if (!selectedItems.contains(itemIndex)) {
+ selectedItems.clear()
+ selectedItems += itemIndex
+ } else {
+ selectedItems.clear()
+ }
+
+ updateIsAnswerAvailable()
+
+ if (selectedItems.isNotEmpty()) {
+ getPendingAnswer(itemIndex)
+ }
+
+ return selectedItems.isNotEmpty()
+ }
+
+ val isAnswerAvailable = ObservableField(false)
+
+ init {
+ val callback: Observable.OnPropertyChangedCallback =
+ object : Observable.OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ selectedAnswerAvailabilityReceiver.onPendingAnswerAvailabilityCheck(
+ selectedItems.isNotEmpty()
+ )
+ }
+ }
+ isAnswerAvailable.addOnPropertyChangedCallback(callback)
+ }
+
+ private fun updateIsAnswerAvailable() {
+ val selectedItemListWasEmpty = isAnswerAvailable.get()
+ if (selectedItems.isNotEmpty() != selectedItemListWasEmpty) {
+ isAnswerAvailable.set(selectedItems.isNotEmpty())
+ }
+ }
+
+ private fun getPendingAnswer(npsScore: Int) {
+ val answer = SurveySelectedAnswer.newBuilder()
+ .setQuestionName(SurveyQuestionName.NPS)
+ .setNpsScore(npsScore)
+ .build()
+ answerHandler.getMultipleChoiceAnswer(answer)
+ }
+
+ override fun getPreviousAnswer(): SurveySelectedAnswer {
+ return SurveySelectedAnswer.getDefaultInstance()
+ }
+
+ override fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) {
+ val selectedAnswerOption = previousAnswer.npsScore
+ selectedItems.apply {
+ clear()
+ add(selectedAnswerOption)
+ }
+
+ updateIsAnswerAvailable()
+
+ selectedAnswerOption.let { optionIndex ->
+ getPendingAnswer(optionIndex)
+ optionItems[optionIndex].isAnswerSelected.set(true)
+ }
+ }
+
+ private fun getNpsOptions(): ObservableArrayList {
+ val observableList = ObservableArrayList()
+ observableList += (0..10).mapIndexed { index, score ->
+ MultipleChoiceOptionContentViewModel(
+ optionContent = score.toString(),
+ itemIndex = index,
+ this
+ )
+ }
+ return observableList
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt
new file mode 100644
index 00000000000..85de88ec26f
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt
@@ -0,0 +1,22 @@
+package org.oppia.android.app.survey.surveyitemviewmodel
+
+import org.oppia.android.app.viewmodel.ObservableViewModel
+
+/**
+ * The root [ObservableViewModel] for all individual items that may be displayed in the survey
+ * fragment recycler view.
+ */
+abstract class SurveyAnswerItemViewModel(val viewType: ViewType) : ObservableViewModel() {
+
+ open fun updateSelection(itemIndex: Int): Boolean {
+ return true
+ }
+
+ /** Corresponds to the type of the view model. */
+ enum class ViewType {
+ MARKET_FIT_OPTIONS,
+ USER_TYPE_OPTIONS,
+ FREE_FORM_ANSWER,
+ NPS_OPTIONS
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt
new file mode 100644
index 00000000000..14482c2775e
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt
@@ -0,0 +1,153 @@
+package org.oppia.android.app.survey.surveyitemviewmodel
+
+import androidx.databinding.Observable
+import androidx.databinding.ObservableField
+import androidx.databinding.ObservableList
+import org.oppia.android.R
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.model.UserTypeAnswer
+import org.oppia.android.app.survey.PreviousAnswerHandler
+import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver
+import org.oppia.android.app.survey.SelectedAnswerHandler
+import org.oppia.android.app.translation.AppLanguageResourceHandler
+import org.oppia.android.app.viewmodel.ObservableArrayList
+import javax.inject.Inject
+
+/** [SurveyAnswerItemViewModel] for providing the type of user question options. */
+class UserTypeItemsViewModel @Inject constructor(
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val selectedAnswerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver,
+ private val answerHandler: SelectedAnswerHandler
+) : SurveyAnswerItemViewModel(ViewType.USER_TYPE_OPTIONS), PreviousAnswerHandler {
+ val optionItems: ObservableList = getUserTypeOptions()
+
+ private val selectedItems: MutableList = mutableListOf()
+
+ override fun updateSelection(itemIndex: Int): Boolean {
+ optionItems.forEach { item -> item.isAnswerSelected.set(false) }
+ if (!selectedItems.contains(itemIndex)) {
+ selectedItems.clear()
+ selectedItems += itemIndex
+ } else {
+ selectedItems.clear()
+ }
+
+ updateIsAnswerAvailable()
+
+ if (selectedItems.isNotEmpty()) {
+ getPendingAnswer(itemIndex)
+ }
+
+ return selectedItems.isNotEmpty()
+ }
+
+ val isAnswerAvailable = ObservableField(false)
+
+ init {
+ val callback: Observable.OnPropertyChangedCallback =
+ object : Observable.OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ selectedAnswerAvailabilityReceiver.onPendingAnswerAvailabilityCheck(
+ selectedItems.isNotEmpty()
+ )
+ }
+ }
+ isAnswerAvailable.addOnPropertyChangedCallback(callback)
+ }
+
+ private fun updateIsAnswerAvailable() {
+ val selectedItemListWasEmpty = isAnswerAvailable.get()
+ if (selectedItems.isNotEmpty() != selectedItemListWasEmpty) {
+ isAnswerAvailable.set(selectedItems.isNotEmpty())
+ }
+ }
+
+ private fun getPendingAnswer(itemIndex: Int) {
+ val typeCase = itemIndex + 1
+ val answerValue = UserTypeAnswer.forNumber(typeCase)
+ val answer = SurveySelectedAnswer.newBuilder()
+ .setQuestionName(SurveyQuestionName.USER_TYPE)
+ .setUserType(answerValue)
+ .build()
+ answerHandler.getMultipleChoiceAnswer(answer)
+ }
+
+ override fun getPreviousAnswer(): SurveySelectedAnswer {
+ return SurveySelectedAnswer.getDefaultInstance()
+ }
+
+ override fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) {
+ // Index 0 corresponds to ANSWER_UNSPECIFIED which is not a valid option so it's filtered out.
+ // Valid enum type numbers start from 1 while list item indices start from 0, hence the minus(1)
+ // to get the correct index to update. Notice that for [getPendingAnswer] we increment the index
+ // to get the correct typeCase to save.
+ val previousSelection = previousAnswer.userType.number.takeIf { it != 0 }?.minus(1)
+
+ selectedItems.apply {
+ clear()
+ previousSelection?.let { optionIndex ->
+ add(optionIndex)
+ updateIsAnswerAvailable()
+ getPendingAnswer(optionIndex)
+ optionItems[optionIndex].isAnswerSelected.set(true)
+ }
+ }
+ }
+
+ private fun getUserTypeOptions(): ObservableArrayList {
+ val observableList = ObservableArrayList()
+ observableList += UserTypeAnswer.values()
+ .filter { it.isValid() }
+ .mapIndexed { index, userTypeOption ->
+ when (userTypeOption) {
+ UserTypeAnswer.LEARNER ->
+ MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.user_type_answer_learner
+ ),
+ index,
+ this
+ )
+ UserTypeAnswer.TEACHER -> MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.user_type_answer_teacher
+ ),
+ index,
+ this
+ )
+
+ UserTypeAnswer.PARENT ->
+ MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.user_type_answer_parent
+ ),
+ index,
+ this
+ )
+
+ UserTypeAnswer.OTHER ->
+ MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.user_type_answer_other
+ ),
+ index,
+ this
+ )
+ else -> throw IllegalStateException("Invalid UserTypeAnswer")
+ }
+ }
+ return observableList
+ }
+
+ companion object {
+
+ /** Returns whether a [UserTypeAnswer] is valid. */
+ fun UserTypeAnswer.isValid(): Boolean {
+ return when (this) {
+ UserTypeAnswer.UNRECOGNIZED, UserTypeAnswer.USER_TYPE_UNSPECIFIED -> false
+ else -> true
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt b/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt
index d9883ed21c5..08726cc7f49 100644
--- a/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt
+++ b/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt
@@ -8,11 +8,14 @@ import org.oppia.android.app.customview.ContinueButtonView
import org.oppia.android.app.customview.LessonThumbnailImageView
import org.oppia.android.app.customview.PromotedStoryCardView
import org.oppia.android.app.customview.SegmentedCircularProgressView
+import org.oppia.android.app.customview.SurveyOnboardingBackgroundView
import org.oppia.android.app.home.promotedlist.ComingSoonTopicsListView
import org.oppia.android.app.home.promotedlist.PromotedStoryListView
import org.oppia.android.app.player.state.DragDropSortInteractionView
import org.oppia.android.app.player.state.ImageRegionSelectionInteractionView
import org.oppia.android.app.player.state.SelectionInteractionView
+import org.oppia.android.app.survey.SurveyMultipleChoiceOptionView
+import org.oppia.android.app.survey.SurveyNpsItemOptionView
// TODO(#59): Restrict access to this implementation by introducing injectors in each view.
@@ -39,4 +42,7 @@ interface ViewComponentImpl : ViewComponent {
fun inject(promotedStoryCardView: PromotedStoryCardView)
fun inject(promotedStoryListView: PromotedStoryListView)
fun inject(segmentedCircularProgressView: SegmentedCircularProgressView)
+ fun inject(surveyOnboardingBackgroundView: SurveyOnboardingBackgroundView)
+ fun inject(surveyMultipleChoiceOptionView: SurveyMultipleChoiceOptionView)
+ fun inject(surveyNpsItemOptionView: SurveyNpsItemOptionView)
}
diff --git a/app/src/main/res/color/component_color_shared_survey_option_selector.xml b/app/src/main/res/color/component_color_shared_survey_option_selector.xml
new file mode 100644
index 00000000000..fe393056c34
--- /dev/null
+++ b/app/src/main/res/color/component_color_shared_survey_option_selector.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_button_white_background_color.xml b/app/src/main/res/drawable/rounded_button_white_background_color.xml
new file mode 100644
index 00000000000..a11197bd37a
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_button_white_background_color.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_button_white_outline_color.xml b/app/src/main/res/drawable/rounded_button_white_outline_color.xml
new file mode 100644
index 00000000000..9745cd92363
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_button_white_outline_color.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml b/app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml
new file mode 100644
index 00000000000..cb6db57bdcf
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_confirmation_dialog_background.xml b/app/src/main/res/drawable/survey_confirmation_dialog_background.xml
new file mode 100644
index 00000000000..a8e76a82fd6
--- /dev/null
+++ b/app/src/main/res/drawable/survey_confirmation_dialog_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_edit_text_background_border.xml b/app/src/main/res/drawable/survey_edit_text_background_border.xml
new file mode 100644
index 00000000000..84258c626cf
--- /dev/null
+++ b/app/src/main/res/drawable/survey_edit_text_background_border.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_next_button_background.xml b/app/src/main/res/drawable/survey_next_button_background.xml
new file mode 100644
index 00000000000..de55a5e10e0
--- /dev/null
+++ b/app/src/main/res/drawable/survey_next_button_background.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_nps_radio_button_background.xml b/app/src/main/res/drawable/survey_nps_radio_button_background.xml
new file mode 100644
index 00000000000..57745803dad
--- /dev/null
+++ b/app/src/main/res/drawable/survey_nps_radio_button_background.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_nps_radio_selected_color.xml b/app/src/main/res/drawable/survey_nps_radio_selected_color.xml
new file mode 100644
index 00000000000..c62b0773a38
--- /dev/null
+++ b/app/src/main/res/drawable/survey_nps_radio_selected_color.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/survey_nps_radio_text_color.xml b/app/src/main/res/drawable/survey_nps_radio_text_color.xml
new file mode 100644
index 00000000000..4d28037d434
--- /dev/null
+++ b/app/src/main/res/drawable/survey_nps_radio_text_color.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml b/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml
new file mode 100644
index 00000000000..b2665b4d899
--- /dev/null
+++ b/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/survey_progress_bar.xml b/app/src/main/res/drawable/survey_progress_bar.xml
new file mode 100644
index 00000000000..f31fa6cfdf6
--- /dev/null
+++ b/app/src/main/res/drawable/survey_progress_bar.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_rounded_disabled_button_background.xml b/app/src/main/res/drawable/survey_rounded_disabled_button_background.xml
new file mode 100644
index 00000000000..b58f04d8764
--- /dev/null
+++ b/app/src/main/res/drawable/survey_rounded_disabled_button_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_submit_button_background.xml b/app/src/main/res/drawable/survey_submit_button_background.xml
new file mode 100644
index 00000000000..f59c05a5547
--- /dev/null
+++ b/app/src/main/res/drawable/survey_submit_button_background.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_activity.xml b/app/src/main/res/layout/survey_activity.xml
new file mode 100644
index 00000000000..351b90d614e
--- /dev/null
+++ b/app/src/main/res/layout/survey_activity.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_exit_confirmation_dialog.xml b/app/src/main/res/layout/survey_exit_confirmation_dialog.xml
new file mode 100644
index 00000000000..78128ced3dd
--- /dev/null
+++ b/app/src/main/res/layout/survey_exit_confirmation_dialog.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_fragment.xml b/app/src/main/res/layout/survey_fragment.xml
new file mode 100644
index 00000000000..07f6770a3d5
--- /dev/null
+++ b/app/src/main/res/layout/survey_fragment.xml
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_free_form_layout.xml b/app/src/main/res/layout/survey_free_form_layout.xml
new file mode 100644
index 00000000000..fbaead829bf
--- /dev/null
+++ b/app/src/main/res/layout/survey_free_form_layout.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_market_fit_question_layout.xml b/app/src/main/res/layout/survey_market_fit_question_layout.xml
new file mode 100644
index 00000000000..79929792d89
--- /dev/null
+++ b/app/src/main/res/layout/survey_market_fit_question_layout.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_multiple_choice_item.xml b/app/src/main/res/layout/survey_multiple_choice_item.xml
new file mode 100644
index 00000000000..5a7e2846800
--- /dev/null
+++ b/app/src/main/res/layout/survey_multiple_choice_item.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_nps_item.xml b/app/src/main/res/layout/survey_nps_item.xml
new file mode 100644
index 00000000000..d7b60e80256
--- /dev/null
+++ b/app/src/main/res/layout/survey_nps_item.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_nps_score_layout.xml b/app/src/main/res/layout/survey_nps_score_layout.xml
new file mode 100644
index 00000000000..8f84603de25
--- /dev/null
+++ b/app/src/main/res/layout/survey_nps_score_layout.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_outro_dialog_fragment.xml b/app/src/main/res/layout/survey_outro_dialog_fragment.xml
new file mode 100644
index 00000000000..0b1719efc04
--- /dev/null
+++ b/app/src/main/res/layout/survey_outro_dialog_fragment.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_user_type_question_layout.xml b/app/src/main/res/layout/survey_user_type_question_layout.xml
new file mode 100644
index 00000000000..a0fa468dde3
--- /dev/null
+++ b/app/src/main/res/layout/survey_user_type_question_layout.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_welcome_dialog_fragment.xml b/app/src/main/res/layout/survey_welcome_dialog_fragment.xml
new file mode 100644
index 00000000000..abbd11759a7
--- /dev/null
+++ b/app/src/main/res/layout/survey_welcome_dialog_fragment.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/color_defs.xml b/app/src/main/res/values/color_defs.xml
index d99a90237e9..27498356b7e 100644
--- a/app/src/main/res/values/color_defs.xml
+++ b/app/src/main/res/values/color_defs.xml
@@ -137,4 +137,10 @@
#AB29CC#CC29B1#CC2970
+ #F5F5F5
+ #F6F6F6
+ #BDCCCC
+
+ #E8E8E8
+ #E2F5F4
diff --git a/app/src/main/res/values/color_palette.xml b/app/src/main/res/values/color_palette.xml
index 8bb2dba3b17..0c34c6b9f99 100644
--- a/app/src/main/res/values/color_palette.xml
+++ b/app/src/main/res/values/color_palette.xml
@@ -244,4 +244,13 @@
@color/color_def_avatar_background_22@color/color_def_avatar_background_23@color/color_def_avatar_background_24
+
+ @color/color_def_survey_background
+ @color/color_def_survey_buttons_white
+ @color/color_def_light_blue
+ @color/color_def_accessible_grey
+ @color/color_def_survey_disabled_button_grey
+ @color/color_def_chooser_grey
+ @color/color_def_persian_green
+ @color/color_def_grey
diff --git a/app/src/main/res/values/component_colors.xml b/app/src/main/res/values/component_colors.xml
index 1daab233d78..59b36435596 100644
--- a/app/src/main/res/values/component_colors.xml
+++ b/app/src/main/res/values/component_colors.xml
@@ -277,4 +277,16 @@
@color/color_palette_color_palette_walkthrough_status_bar_color@color/color_palette_walkthrough_activity_rounded_corners_color
+
+ @color/color_palette_survey_background_color
+ @color/color_palette_survey_shared_button_color
+ @color/color_palette_primary_color
+ @color/color_palette_primary_color
+ @color/color_palette_survey_dialog_stroke_color
+ @color/color_palette_survey_radio_button_color
+ @color/color_palette_survey_radio_button_color
+ @color/color_palette_survey_disabled_button_color
+ @color/color_palette_survey_disabled_button_text_color
+ @color/color_palette_button_text_color
+ @color/color_palette_edit_text_unselected_color
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 197f890ed67..2211fe16ec0 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -457,7 +457,7 @@
304dp
-
+
128dp
@@ -575,7 +575,7 @@
20dp20dp144dp
-
+
8dp12dp
@@ -645,7 +645,7 @@
8dp
-
+
172dp0dp
@@ -660,7 +660,7 @@
132dp
-
+
160dp
@@ -746,7 +746,7 @@
12dp12dp12dp
-
+
16dp16dp
@@ -768,4 +768,20 @@
8dp8dp4dp
+
+
+ 12dp
+ 20dp
+ 12dp
+ 28dp
+ 28dp
+ 28dp
+ 28dp
+ 4dp
+ 28sp
+ 14sp
+ 32dp
+ 32dp
+ 8dp
+ 16dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ed1a4bca9fd..cad5b0772ed 100755
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -592,4 +592,35 @@
Please select all correct choices.You may select more choices.No more than %s choices may be selected.
+ Survey
+ Previous
+ Submit
+ Leave your feedback here
+ Continue Survey
+ Exit
+ Exit Survey
+ Are you sure you want to exit the survey?
+ Your feedback helps us serve learners like you better. Would you like to complete a short survey about your experience?
+ Begin Survey
+ Maybe Later
+ Thank you for completing the survey. We hope you\'ve enjoyed using %s!
+ Exit survey
+ We\'d love your feedback!
+ Thank you
+ 0 - Not at all likely
+ 10 - Extremely likely
+ Please select one of the following:
+ I am a learner
+ I am a teacher
+ I am a parent
+ Other
+ How would you feel if you could no longer use %s?
+ Very disappointed
+ Somewhat disappointed
+ Not disappointed
+ N/A - I don\’t use %s anymore
+ We are glad you have enjoyed your experience with %s. Please share what helped you the most:
+ Thanks for responding! How can we provide a better experience?
+ Help us improve your experience! Please share the primary reason for your score:
+ On a scale from 0–10, how likely are you to recommend %s to a friend or colleague?
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index af9d4434c4a..24bc4f073a0 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -480,4 +480,155 @@
4dp4dp
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdActivityTest.kt
index 3356c42167c..901453c91dd 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdActivityTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdActivityTest.kt
@@ -117,22 +117,33 @@ class ProfileAndDeviceIdActivityTest {
private const val FIXED_APPLICATION_ID = 123456789L
}
- @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
- @get:Rule val oppiaTestRule = OppiaTestRule()
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
+ @get:Rule
+ val oppiaTestRule = OppiaTestRule()
+
@get:Rule
var activityRule =
ActivityScenarioRule(
ProfileAndDeviceIdActivity.createIntent(ApplicationProvider.getApplicationContext())
)
- @Inject lateinit var profileTestHelper: ProfileTestHelper
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
- @Inject lateinit var context: Context
- @Inject lateinit var oppiaLogger: OppiaLogger
- @Inject lateinit var oppiaClock: OppiaClock
- @Inject lateinit var networkConnectionUtil: NetworkConnectionDebugUtil
- @Inject lateinit var logUploadWorkerFactory: LogUploadWorkerFactory
- @Inject lateinit var syncStatusManager: SyncStatusManager
+ @Inject
+ lateinit var profileTestHelper: ProfileTestHelper
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+ @Inject
+ lateinit var context: Context
+ @Inject
+ lateinit var oppiaLogger: OppiaLogger
+ @Inject
+ lateinit var oppiaClock: OppiaClock
+ @Inject
+ lateinit var networkConnectionUtil: NetworkConnectionDebugUtil
+ @Inject
+ lateinit var logUploadWorkerFactory: LogUploadWorkerFactory
+ @Inject
+ lateinit var syncStatusManager: SyncStatusManager
@Before
fun setUp() {
diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsFragmentTest.kt
index 7c2fc2d6534..40ed8ae48fb 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsFragmentTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsFragmentTest.kt
@@ -115,14 +115,21 @@ private const val TEST_SUB_TOPIC_ID = 1
qualifiers = "port-xxhdpi"
)
class ViewEventLogsFragmentTest {
- @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
- @get:Rule val oppiaTestRule = OppiaTestRule()
-
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
- @Inject lateinit var context: Context
- @Inject lateinit var oppiaLogger: OppiaLogger
- @Inject lateinit var analyticsController: AnalyticsController
- @Inject lateinit var fakeOppiaClock: FakeOppiaClock
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
+ @get:Rule
+ val oppiaTestRule = OppiaTestRule()
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+ @Inject
+ lateinit var context: Context
+ @Inject
+ lateinit var oppiaLogger: OppiaLogger
+ @Inject
+ lateinit var analyticsController: AnalyticsController
+ @Inject
+ lateinit var fakeOppiaClock: FakeOppiaClock
@Before
fun setUp() {
diff --git a/app/src/sharedTest/java/org/oppia/android/app/faq/FaqListActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/faq/FaqListActivityTest.kt
index 38248410ef4..1f9086d1c4d 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/faq/FaqListActivityTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/faq/FaqListActivityTest.kt
@@ -168,7 +168,6 @@ class FaqListActivityTest {
SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
-
]
)
interface TestApplicationComponent : ApplicationComponent {
diff --git a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfilePictureActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfilePictureActivityTest.kt
index 8f423ac331d..5c870dc35dc 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfilePictureActivityTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfilePictureActivityTest.kt
@@ -220,7 +220,7 @@ class ProfilePictureActivityTest {
LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
- CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : ApplicationComponent {
diff --git a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressActivityTest.kt
index b366166226a..0e16912ffb1 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressActivityTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressActivityTest.kt
@@ -170,7 +170,7 @@ class ProfileProgressActivityTest {
LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
- CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : ApplicationComponent {
diff --git a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressFragmentTest.kt
index 4e73f745480..15d869c6bcc 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressFragmentTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressFragmentTest.kt
@@ -923,7 +923,7 @@ class ProfileProgressFragmentTest {
LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
- CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : ApplicationComponent {
diff --git a/app/src/sharedTest/java/org/oppia/android/app/splash/SplashActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/splash/SplashActivityTest.kt
index 124c93b60c4..4483d5e3e6c 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/splash/SplashActivityTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/splash/SplashActivityTest.kt
@@ -149,15 +149,23 @@ class SplashActivityTest {
@get:Rule
val oppiaTestRule = OppiaTestRule()
- @Inject lateinit var context: Context
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
- @Inject lateinit var fakeMetaDataRetriever: FakeExpirationMetaDataRetriever
- @Inject lateinit var appLanguageLocaleHandler: AppLanguageLocaleHandler
- @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory
- @Inject lateinit var appStartupStateController: AppStartupStateController
-
- @Parameter lateinit var firstOpen: String
- @Parameter lateinit var secondOpen: String
+ @Inject
+ lateinit var context: Context
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+ @Inject
+ lateinit var fakeMetaDataRetriever: FakeExpirationMetaDataRetriever
+ @Inject
+ lateinit var appLanguageLocaleHandler: AppLanguageLocaleHandler
+ @Inject
+ lateinit var monitorFactory: DataProviderTestMonitor.Factory
+ @Inject
+ lateinit var appStartupStateController: AppStartupStateController
+
+ @Parameter
+ lateinit var firstOpen: String
+ @Parameter
+ lateinit var secondOpen: String
private val expirationDateFormat by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) }
private val firstOpenFlavor by lazy { BuildFlavor.valueOf(firstOpen) }
diff --git a/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyActivityTest.kt
new file mode 100644
index 00000000000..980d4b0921d
--- /dev/null
+++ b/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyActivityTest.kt
@@ -0,0 +1,224 @@
+package org.oppia.android.app.survey
+
+import android.app.Application
+import android.content.Context
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.espresso.intent.Intents
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.rule.ActivityTestRule
+import com.google.common.truth.Truth.assertThat
+import dagger.Component
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.R
+import org.oppia.android.app.activity.ActivityComponent
+import org.oppia.android.app.activity.ActivityComponentFactory
+import org.oppia.android.app.activity.route.ActivityRouterModule
+import org.oppia.android.app.application.ApplicationComponent
+import org.oppia.android.app.application.ApplicationInjector
+import org.oppia.android.app.application.ApplicationInjectorProvider
+import org.oppia.android.app.application.ApplicationModule
+import org.oppia.android.app.application.ApplicationStartupListenerModule
+import org.oppia.android.app.application.testing.TestingBuildFlavorModule
+import org.oppia.android.app.devoptions.DeveloperOptionsModule
+import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.ScreenName
+import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule
+import org.oppia.android.app.shim.ViewBindingShimModule
+import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule
+import org.oppia.android.data.backends.gae.NetworkConfigProdModule
+import org.oppia.android.data.backends.gae.NetworkModule
+import org.oppia.android.domain.classify.InteractionsModule
+import org.oppia.android.domain.classify.rules.algebraicexpressioninput.AlgebraicExpressionInputModule
+import org.oppia.android.domain.classify.rules.continueinteraction.ContinueModule
+import org.oppia.android.domain.classify.rules.dragAndDropSortInput.DragDropSortInputModule
+import org.oppia.android.domain.classify.rules.fractioninput.FractionInputModule
+import org.oppia.android.domain.classify.rules.imageClickInput.ImageClickInputModule
+import org.oppia.android.domain.classify.rules.itemselectioninput.ItemSelectionInputModule
+import org.oppia.android.domain.classify.rules.mathequationinput.MathEquationInputModule
+import org.oppia.android.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule
+import org.oppia.android.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule
+import org.oppia.android.domain.classify.rules.numericexpressioninput.NumericExpressionInputModule
+import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule
+import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule
+import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule
+import org.oppia.android.domain.exploration.ExplorationProgressModule
+import org.oppia.android.domain.exploration.ExplorationStorageModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule
+import org.oppia.android.domain.onboarding.ExpirationMetaDataRetrieverModule
+import org.oppia.android.domain.oppialogger.LogStorageModule
+import org.oppia.android.domain.oppialogger.LoggingIdentifierModule
+import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
+import org.oppia.android.domain.oppialogger.analytics.CpuPerformanceSnapshotterModule
+import org.oppia.android.domain.oppialogger.logscheduler.MetricLogSchedulerModule
+import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule
+import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule
+import org.oppia.android.domain.question.QuestionModule
+import org.oppia.android.domain.topic.PrimeTopicAssetsControllerModule
+import org.oppia.android.domain.topic.TEST_TOPIC_ID_0
+import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule
+import org.oppia.android.testing.OppiaTestRule
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.junit.InitializeDefaultLocaleRule
+import org.oppia.android.testing.platformparameter.TestPlatformParameterModule
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.testing.time.FakeOppiaClockModule
+import org.oppia.android.util.accessibility.AccessibilityTestModule
+import org.oppia.android.util.caching.AssetModule
+import org.oppia.android.util.caching.testing.CachingTestModule
+import org.oppia.android.util.gcsresource.GcsResourceModule
+import org.oppia.android.util.locale.LocaleProdModule
+import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.extractCurrentAppScreenName
+import org.oppia.android.util.logging.EventLoggingConfigurationModule
+import org.oppia.android.util.logging.LoggerModule
+import org.oppia.android.util.logging.SyncStatusModule
+import org.oppia.android.util.logging.firebase.FirebaseLogUploaderModule
+import org.oppia.android.util.networking.NetworkConnectionDebugUtilModule
+import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule
+import org.oppia.android.util.parser.image.GlideImageLoaderModule
+import org.oppia.android.util.parser.image.ImageParsingModule
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [SurveyActivity]. */
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(
+ application = SurveyActivityTest.TestApplication::class,
+ qualifiers = "port-xxhdpi"
+)
+class SurveyActivityTest {
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
+
+ @get:Rule
+ val oppiaTestRule = OppiaTestRule()
+
+ private val profileId = ProfileId.newBuilder().setInternalId(0).build()
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ @Inject
+ lateinit var context: Context
+
+ @get:Rule
+ val activityTestRule = ActivityTestRule(
+ SurveyActivity::class.java,
+ /* initialTouchMode= */ true,
+ /* launchActivity= */ false
+ )
+
+ @Before
+ fun setUp() {
+ Intents.init()
+ setUpTestApplicationComponent()
+ testCoroutineDispatchers.registerIdlingResource()
+ }
+
+ @After
+ fun tearDown() {
+ testCoroutineDispatchers.unregisterIdlingResource()
+ Intents.release()
+ }
+
+ @Test
+ fun testSurveyActivity_hasCorrectActivityLabel() {
+ activityTestRule.launchActivity(createSurveyActivityIntent(profileId))
+ val title = activityTestRule.activity.title
+
+ // Verify that the activity label is correct as a proxy to verify TalkBack will announce the
+ // correct string when it's read out.
+ assertThat(title).isEqualTo(context.getString(R.string.survey_activity_title))
+ }
+
+ @Test
+ fun testActivity_createIntent_verifyScreenNameInIntent() {
+ val currentScreenNameWithIntent = SurveyActivity.createSurveyActivityIntent(
+ context, profileId, TEST_TOPIC_ID_0
+ ).extractCurrentAppScreenName()
+
+ assertThat(currentScreenNameWithIntent).isEqualTo(ScreenName.SURVEY_ACTIVITY)
+ }
+
+ private fun setUpTestApplicationComponent() {
+ ApplicationProvider.getApplicationContext()
+ .inject(this)
+ }
+
+ private fun createSurveyActivityIntent(profileId: ProfileId): Intent {
+ return SurveyActivity.createSurveyActivityIntent(
+ context = context,
+ profileId = profileId,
+ TEST_TOPIC_ID_0
+ )
+ }
+
+ // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them.
+ @Singleton
+ @Component(
+ modules = [
+ RobolectricModule::class,
+ TestPlatformParameterModule::class, PlatformParameterSingletonModule::class,
+ TestDispatcherModule::class, ApplicationModule::class,
+ LoggerModule::class, ContinueModule::class, FractionInputModule::class,
+ ItemSelectionInputModule::class, MultipleChoiceInputModule::class,
+ NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class,
+ DragDropSortInputModule::class, ImageClickInputModule::class, InteractionsModule::class,
+ GcsResourceModule::class, GlideImageLoaderModule::class, ImageParsingModule::class,
+ HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class,
+ AccessibilityTestModule::class, LogStorageModule::class, CachingTestModule::class,
+ PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class,
+ ViewBindingShimModule::class, RatioInputModule::class, WorkManagerConfigurationModule::class,
+ ApplicationStartupListenerModule::class, LogReportWorkerModule::class,
+ HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class,
+ FirebaseLogUploaderModule::class, FakeOppiaClockModule::class,
+ DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class,
+ ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class,
+ NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class,
+ AssetModule::class, LocaleProdModule::class, ActivityRecreatorTestModule::class,
+ NumericExpressionInputModule::class, AlgebraicExpressionInputModule::class,
+ MathEquationInputModule::class, SplitScreenInteractionModule::class,
+ LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
+ SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
+ EventLoggingConfigurationModule::class, ActivityRouterModule::class,
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
+ ]
+ )
+ interface TestApplicationComponent : ApplicationComponent {
+ @Component.Builder
+ interface Builder : ApplicationComponent.Builder
+
+ fun inject(surveyActivityTest: SurveyActivityTest)
+ }
+
+ class TestApplication : Application(), ActivityComponentFactory, ApplicationInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerSurveyActivityTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build() as TestApplicationComponent
+ }
+
+ fun inject(surveyActivityTest: SurveyActivityTest) {
+ component.inject(surveyActivityTest)
+ }
+
+ override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent {
+ return component.getActivityComponentBuilderProvider().get().setActivity(activity).build()
+ }
+
+ override fun getApplicationInjector(): ApplicationInjector = component
+ }
+}
diff --git a/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyFragmentTest.kt
new file mode 100644
index 00000000000..52ba4de7e87
--- /dev/null
+++ b/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyFragmentTest.kt
@@ -0,0 +1,628 @@
+package org.oppia.android.app.survey
+
+import android.app.Application
+import android.content.Context
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.core.app.ActivityScenario.launch
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.contrib.RecyclerViewActions
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.matcher.RootMatchers.isDialog
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
+import androidx.test.espresso.matcher.ViewMatchers.isChecked
+import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.isEnabled
+import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
+import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.rule.ActivityTestRule
+import com.google.common.truth.Truth.assertThat
+import dagger.Component
+import org.hamcrest.CoreMatchers.allOf
+import org.hamcrest.CoreMatchers.not
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.R
+import org.oppia.android.app.activity.ActivityComponent
+import org.oppia.android.app.activity.ActivityComponentFactory
+import org.oppia.android.app.activity.route.ActivityRouterModule
+import org.oppia.android.app.application.ApplicationComponent
+import org.oppia.android.app.application.ApplicationInjector
+import org.oppia.android.app.application.ApplicationInjectorProvider
+import org.oppia.android.app.application.ApplicationModule
+import org.oppia.android.app.application.ApplicationStartupListenerModule
+import org.oppia.android.app.application.testing.TestingBuildFlavorModule
+import org.oppia.android.app.devoptions.DeveloperOptionsModule
+import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.ScreenName
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule
+import org.oppia.android.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView
+import org.oppia.android.app.shim.ViewBindingShimModule
+import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule
+import org.oppia.android.data.backends.gae.NetworkConfigProdModule
+import org.oppia.android.data.backends.gae.NetworkModule
+import org.oppia.android.domain.classify.InteractionsModule
+import org.oppia.android.domain.classify.rules.algebraicexpressioninput.AlgebraicExpressionInputModule
+import org.oppia.android.domain.classify.rules.continueinteraction.ContinueModule
+import org.oppia.android.domain.classify.rules.dragAndDropSortInput.DragDropSortInputModule
+import org.oppia.android.domain.classify.rules.fractioninput.FractionInputModule
+import org.oppia.android.domain.classify.rules.imageClickInput.ImageClickInputModule
+import org.oppia.android.domain.classify.rules.itemselectioninput.ItemSelectionInputModule
+import org.oppia.android.domain.classify.rules.mathequationinput.MathEquationInputModule
+import org.oppia.android.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule
+import org.oppia.android.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule
+import org.oppia.android.domain.classify.rules.numericexpressioninput.NumericExpressionInputModule
+import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule
+import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule
+import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule
+import org.oppia.android.domain.exploration.ExplorationProgressModule
+import org.oppia.android.domain.exploration.ExplorationStorageModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule
+import org.oppia.android.domain.onboarding.ExpirationMetaDataRetrieverModule
+import org.oppia.android.domain.oppialogger.LogStorageModule
+import org.oppia.android.domain.oppialogger.LoggingIdentifierModule
+import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
+import org.oppia.android.domain.oppialogger.analytics.CpuPerformanceSnapshotterModule
+import org.oppia.android.domain.oppialogger.logscheduler.MetricLogSchedulerModule
+import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule
+import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule
+import org.oppia.android.domain.question.QuestionModule
+import org.oppia.android.domain.survey.SurveyController
+import org.oppia.android.domain.topic.PrimeTopicAssetsControllerModule
+import org.oppia.android.domain.topic.TEST_TOPIC_ID_0
+import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule
+import org.oppia.android.testing.FakeAnalyticsEventLogger
+import org.oppia.android.testing.OppiaTestRule
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.junit.InitializeDefaultLocaleRule
+import org.oppia.android.testing.platformparameter.TestPlatformParameterModule
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.testing.time.FakeOppiaClock
+import org.oppia.android.testing.time.FakeOppiaClockModule
+import org.oppia.android.util.accessibility.AccessibilityTestModule
+import org.oppia.android.util.caching.AssetModule
+import org.oppia.android.util.caching.testing.CachingTestModule
+import org.oppia.android.util.gcsresource.GcsResourceModule
+import org.oppia.android.util.locale.LocaleProdModule
+import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.extractCurrentAppScreenName
+import org.oppia.android.util.logging.EventLoggingConfigurationModule
+import org.oppia.android.util.logging.LoggerModule
+import org.oppia.android.util.logging.SyncStatusModule
+import org.oppia.android.util.logging.firebase.FirebaseLogUploaderModule
+import org.oppia.android.util.networking.NetworkConnectionDebugUtilModule
+import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule
+import org.oppia.android.util.parser.image.GlideImageLoaderModule
+import org.oppia.android.util.parser.image.ImageParsingModule
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [SurveyFragment]. */
+// FunctionName: test names are conventionally named with underscores.
+@Suppress("FunctionName")
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(
+ application = SurveyFragmentTest.TestApplication::class,
+ qualifiers = "port-xxhdpi"
+)
+class SurveyFragmentTest {
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
+
+ @get:Rule
+ val oppiaTestRule = OppiaTestRule()
+
+ @get:Rule
+ var activityTestRule: ActivityTestRule = ActivityTestRule(
+ SurveyActivity::class.java, /* initialTouchMode= */ true, /* launchActivity= */ false
+ )
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ @Inject
+ lateinit var fakeOppiaClock: FakeOppiaClock
+
+ @Inject
+ lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
+
+ @Inject
+ lateinit var context: Context
+
+ @Inject
+ lateinit var surveyController: SurveyController
+
+ private val profileId = ProfileId.newBuilder().setInternalId(0).build()
+
+ @Before
+ fun setup() {
+ Intents.init()
+ setUpTestApplicationComponent()
+ testCoroutineDispatchers.registerIdlingResource()
+ }
+
+ @After
+ fun tearDown() {
+ testCoroutineDispatchers.unregisterIdlingResource()
+ Intents.release()
+ }
+
+ @Test
+ fun testSurveyActivity_createIntent_verifyScreenNameInIntent() {
+ val screenName = createSurveyActivityIntent()
+ .extractCurrentAppScreenName()
+
+ assertThat(screenName).isEqualTo(ScreenName.SURVEY_ACTIVITY)
+ }
+
+ @Test
+ fun testSurveyFragment_closeButtonIsDisplayed() {
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withContentDescription(R.string.survey_exit_button_description))
+ .check(
+ matches(
+ withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)
+ )
+ )
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_progressBarIsDisplayed() {
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withId(R.id.survey_progress_bar))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_progressTextIsDisplayed() {
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withId(R.id.survey_progress_text))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
+ onView(withId(R.id.survey_progress_text))
+ .check(matches(withText("25%")))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_navigationContainerIsDisplayed() {
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withId(R.id.survey_navigation_buttons_container))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_beginSurvey_initialQuestionIsDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withText(R.string.user_type_question))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_next_button))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_previous_button))
+ .check(matches(not(isDisplayed())))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_beginSurvey_initialQuestion_correctOptionsDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withId(R.id.survey_answers_recycler_view)).perform(
+ RecyclerViewActions.scrollToPosition(
+ 0
+ )
+ ).check(matches(hasDescendant(withText(R.string.user_type_answer_learner))))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_beginSurvey_closeButtonClicked_exitConfirmationDialogDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ onView(withContentDescription(R.string.navigate_up)).perform(click())
+ onView(withText(context.getString(R.string.survey_exit_confirmation_text)))
+ .inRoot(isDialog())
+ .check(matches(isDisplayed()))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_nextButtonClicked_marketFitQuestionIsDisplayedWithCorrectOptions() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ onView(withText("How would you feel if you could no longer use Oppia?"))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_next_button))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_previous_button))
+ .check(matches(isDisplayed()))
+
+ onView(withId(R.id.survey_answers_recycler_view)).perform(
+ RecyclerViewActions.scrollToPosition(
+ 0
+ )
+ ).check(matches(hasDescendant(withText(R.string.market_fit_answer_very_disappointed))))
+ }
+ }
+
+ @Test
+ fun testSurveyNavigation_submitMarketFitAnswer_NpsQuestionIsDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Select and submit userTypeAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit marketFitAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ onView(
+ withText(
+ "On a scale from 0–10, how likely are you to recommend Oppia to a friend" +
+ " or colleague?"
+ )
+ )
+ .check(matches(isDisplayed()))
+
+ onView(withId(R.id.survey_answers_recycler_view))
+ .check(matches(hasDescendant(withText("0"))))
+ onView(withId(R.id.survey_answers_recycler_view))
+ .check(matches(hasDescendant(withText("5"))))
+ onView(withId(R.id.survey_answers_recycler_view))
+ .check(matches(hasDescendant(withText("6"))))
+ onView(withId(R.id.survey_answers_recycler_view))
+ .check(matches(hasDescendant(withText("10"))))
+ }
+ }
+
+ @Test
+ fun testSurveyNavigation_submitNpsScoreOf3_detractorFeedbackQuestionIsDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Select and submit userTypeAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit marketFitAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit NpsAnswer
+ selectNpsAnswerAndMoveToNextQuestion(3)
+
+ onView(withText(R.string.nps_detractor_feedback_question))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.submit_button))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_previous_button))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_next_button))
+ .check(matches(not(isDisplayed())))
+ }
+ }
+
+ @Test
+ fun testSurveyNavigation_submitNpsScoreOf8_passiveFeedbackQuestionIsDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Select and submit userTypeAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit marketFitAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit NpsAnswer
+ selectNpsAnswerAndMoveToNextQuestion(8)
+
+ onView(withText(R.string.nps_passive_feedback_question))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.submit_button))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_previous_button))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_next_button))
+ .check(matches(not(isDisplayed())))
+ }
+ }
+
+ @Test
+ fun testSurveyNavigation_submitNpsScoreOf10_promoterFeedbackQuestionIsDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Select and submit userTypeAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit marketFitAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit NpsAnswer
+ selectNpsAnswerAndMoveToNextQuestion(10)
+
+ onView(
+ withText(
+ "We are glad you have enjoyed your experience with Oppia. Please share " +
+ "what helped you the most:"
+ )
+ ).check(matches(isDisplayed()))
+
+ onView(withId(R.id.submit_button)).check(matches(isDisplayed()))
+ onView(withId(R.id.survey_previous_button)).check(matches(isDisplayed()))
+ onView(withId(R.id.survey_next_button)).check(matches(not(isDisplayed())))
+ }
+ }
+
+ @Test
+ fun testNavigation_moveToNextQuestion_thenMoveToPreviousQuestion_previousSelectionIsRestored() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Select and submit userTypeAnswer
+ // Index 0 corresponds to "I am a learner"
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Move back to previous question
+ moveToPreviousQuestion()
+
+ // Next button is enabled if an answer is available
+ onView(withId(R.id.survey_next_button)).check(matches(isEnabled()))
+
+ onView(
+ allOf(
+ withId(R.id.multiple_choice_radio_button),
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = 0,
+ targetViewId = R.id.multiple_choice_radio_button
+ )
+ )
+ ).check(matches(isChecked()))
+
+ onView(
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = 0,
+ targetViewId = R.id.multiple_choice_content_text_view
+ )
+ ).check(matches(withText(R.string.user_type_answer_learner)))
+ }
+ }
+
+ @Test
+ fun testNavigation_moveTwoQuestionsAhead_thenMoveToInitialQuestion_previousSelectionIsRestored() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Select and submit userTypeAnswer
+ // Index 0 corresponds to "I am a learner"
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Submit marketFitAnswer and move to question 3
+ // Index 0 corresponds to "Very Disappointed"
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Move back to marketFit question
+ moveToPreviousQuestion()
+
+ // Assert marketFit answer selection is restored
+ onView(
+ allOf(
+ withId(R.id.multiple_choice_radio_button),
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = 0,
+ targetViewId = R.id.multiple_choice_radio_button
+ )
+ )
+ ).check(matches(isChecked()))
+
+ onView(
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = 0,
+ targetViewId = R.id.multiple_choice_content_text_view
+ )
+ ).check(matches(withText(R.string.market_fit_answer_very_disappointed)))
+
+ // Move back to UserType question
+ moveToPreviousQuestion()
+
+ // Assert UserType answer selection is restored
+ onView(
+ allOf(
+ withId(R.id.multiple_choice_radio_button),
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = 0,
+ targetViewId = R.id.multiple_choice_radio_button
+ )
+ )
+ ).check(matches(isChecked()))
+
+ onView(
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = 0,
+ targetViewId = R.id.multiple_choice_content_text_view
+ )
+ ).check(matches(withText(R.string.user_type_answer_learner)))
+ }
+ }
+
+ private fun selectNpsAnswerAndMoveToNextQuestion(npsScore: Int) {
+ onView(
+ allOf(
+ withText(npsScore.toString()),
+ isDescendantOfA(withId(R.id.survey_answers_recycler_view))
+ )
+ ).perform(click())
+ testCoroutineDispatchers.runCurrent()
+
+ onView(withId(R.id.survey_next_button)).perform(click())
+ testCoroutineDispatchers.runCurrent()
+ }
+
+ private fun selectMultiChoiceAnswerAndMoveToNextQuestion(choiceIndex: Int) {
+ onView(
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = choiceIndex,
+ targetViewId = R.id.multiple_choice_radio_button
+ )
+ ).perform(click())
+ testCoroutineDispatchers.runCurrent()
+
+ onView(withId(R.id.survey_next_button)).perform(click())
+ testCoroutineDispatchers.runCurrent()
+ }
+
+ private fun moveToPreviousQuestion() {
+ onView(withId(R.id.survey_previous_button)).perform(click())
+ testCoroutineDispatchers.runCurrent()
+ }
+
+ private fun startSurveySession() {
+ val questions = listOf(
+ SurveyQuestionName.USER_TYPE,
+ SurveyQuestionName.MARKET_FIT,
+ SurveyQuestionName.NPS
+ )
+ surveyController.startSurveySession(questions)
+ testCoroutineDispatchers.runCurrent()
+ }
+
+ private fun createSurveyActivityIntent(): Intent {
+ return SurveyActivity.createSurveyActivityIntent(
+ context = context,
+ profileId = profileId,
+ TEST_TOPIC_ID_0
+ )
+ }
+
+ private fun setUpTestApplicationComponent() {
+ ApplicationProvider.getApplicationContext().inject(this)
+ }
+
+ // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them.
+ @Singleton
+ @Component(
+ modules = [
+ TestPlatformParameterModule::class, RobolectricModule::class,
+ TestDispatcherModule::class, ApplicationModule::class,
+ LoggerModule::class, ContinueModule::class, FractionInputModule::class,
+ ItemSelectionInputModule::class, MultipleChoiceInputModule::class,
+ NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class,
+ DragDropSortInputModule::class, ImageClickInputModule::class, InteractionsModule::class,
+ GcsResourceModule::class, GlideImageLoaderModule::class, ImageParsingModule::class,
+ HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class,
+ AccessibilityTestModule::class, LogStorageModule::class, CachingTestModule::class,
+ PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class,
+ ViewBindingShimModule::class, RatioInputModule::class, WorkManagerConfigurationModule::class,
+ ApplicationStartupListenerModule::class, LogReportWorkerModule::class,
+ HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class,
+ FirebaseLogUploaderModule::class, FakeOppiaClockModule::class,
+ DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class,
+ ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class,
+ NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class,
+ AssetModule::class, LocaleProdModule::class, ActivityRecreatorTestModule::class,
+ PlatformParameterSingletonModule::class,
+ NumericExpressionInputModule::class, AlgebraicExpressionInputModule::class,
+ MathEquationInputModule::class, SplitScreenInteractionModule::class,
+ LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
+ SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
+ EventLoggingConfigurationModule::class, ActivityRouterModule::class,
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
+ ]
+ )
+ interface TestApplicationComponent : ApplicationComponent {
+ @Component.Builder
+ interface Builder : ApplicationComponent.Builder
+
+ fun inject(surveyFragmentTest: SurveyFragmentTest)
+ }
+
+ class TestApplication : Application(), ActivityComponentFactory, ApplicationInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerSurveyFragmentTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build() as TestApplicationComponent
+ }
+
+ fun inject(surveyFragmentTest: SurveyFragmentTest) {
+ component.inject(surveyFragmentTest)
+ }
+
+ override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent {
+ return component.getActivityComponentBuilderProvider().get().setActivity(activity).build()
+ }
+
+ override fun getApplicationInjector(): ApplicationInjector = component
+ }
+}
diff --git a/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt
index a63b6f492bc..50527e24b88 100644
--- a/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt
+++ b/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt
@@ -96,10 +96,14 @@ import javax.inject.Singleton
qualifiers = "port-xxhdpi"
)
class HomeActivityLocalTest {
- @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
- @Inject lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+ @Inject
+ lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
private val internalProfileId: Int = 1
diff --git a/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt
index 763ea7ea10d..c3e905658cc 100644
--- a/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt
+++ b/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt
@@ -223,7 +223,7 @@ class ExplorationActivityLocalTest {
LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
- CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : ApplicationComponent {
diff --git a/app/src/test/java/org/oppia/android/app/translation/ActivityLanguageLocaleHandlerTest.kt b/app/src/test/java/org/oppia/android/app/translation/ActivityLanguageLocaleHandlerTest.kt
index 5db1a12b695..82ae2bc2ef8 100644
--- a/app/src/test/java/org/oppia/android/app/translation/ActivityLanguageLocaleHandlerTest.kt
+++ b/app/src/test/java/org/oppia/android/app/translation/ActivityLanguageLocaleHandlerTest.kt
@@ -128,10 +128,13 @@ class ActivityLanguageLocaleHandlerTest {
@Inject
lateinit var context: Context
+
@Inject
lateinit var appLanguageLocaleHandler: AppLanguageLocaleHandler
+
@Inject
lateinit var translationController: TranslationController
+
@Inject
lateinit var monitorFactory: DataProviderTestMonitor.Factory
@@ -327,7 +330,7 @@ class ActivityLanguageLocaleHandlerTest {
LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
- CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class,
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
]
)
diff --git a/app/src/test/java/org/oppia/android/app/utility/datetime/DateTimeUtilTest.kt b/app/src/test/java/org/oppia/android/app/utility/datetime/DateTimeUtilTest.kt
index 9cc8ae2c66b..926ea83dbec 100644
--- a/app/src/test/java/org/oppia/android/app/utility/datetime/DateTimeUtilTest.kt
+++ b/app/src/test/java/org/oppia/android/app/utility/datetime/DateTimeUtilTest.kt
@@ -90,12 +90,16 @@ import javax.inject.Singleton
// Time Tue, 23 April 2019 21:26:12
private const val EVENING_TIMESTAMP = 1556054772000
+
// Time: Tue, Apr 23 2019 23:22:00
private const val LATE_NIGHT_TIMESTAMP = 1556061720000
+
// Time: Wed, Apr 24 2019 08:22:00
private const val EARLY_MORNING_TIMESTAMP = 1556094120000
+
// Time: Wed, 24 April 2019 10:30:12
private const val MID_MORNING_TIMESTAMP = 1556101812000
+
// Time: Tue, Apr 23 2019 14:22:00
private const val AFTERNOON_TIMESTAMP = 1556029320000
@@ -109,6 +113,7 @@ class DateTimeUtilTest {
@Inject
lateinit var context: Context
+
@Inject
lateinit var fakeOppiaClock: FakeOppiaClock
@@ -224,7 +229,7 @@ class DateTimeUtilTest {
SyncStatusModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
CpuPerformanceSnapshotterModule::class, AnalyticsStartupListenerTestModule::class,
- ExplorationProgressModule::class
+ ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : ApplicationComponent {
diff --git a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt
index 53de75416ae..1b3293d56f5 100644
--- a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt
+++ b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt
@@ -40,7 +40,7 @@ import org.oppia.android.util.platformparameter.LowestSupportedApiLevel
import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS
import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS_DEFAULT_VALUE
import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES
-import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VAL
+import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
import org.oppia.android.util.platformparameter.NpsSurveyGracePeriodInDays
import org.oppia.android.util.platformparameter.NpsSurveyMinimumAggregateLearningTimeInATopicInMinutes
import org.oppia.android.util.platformparameter.OPTIONAL_APP_UPDATE_VERSION_CODE
@@ -297,7 +297,7 @@ class PlatformParameterAlphaKenyaModule {
return platformParameterSingleton.getIntegerPlatformParameter(
NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES
) ?: PlatformParameterValue.createDefaultParameter(
- NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VAL
+ NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
)
}
}
diff --git a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaModule.kt b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaModule.kt
index 29307b62b1c..8addae7b9fd 100644
--- a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaModule.kt
+++ b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaModule.kt
@@ -38,6 +38,12 @@ import org.oppia.android.util.platformparameter.LOGGING_LEARNER_STUDY_IDS_DEFAUL
import org.oppia.android.util.platformparameter.LOWEST_SUPPORTED_API_LEVEL
import org.oppia.android.util.platformparameter.LOWEST_SUPPORTED_API_LEVEL_DEFAULT_VALUE
import org.oppia.android.util.platformparameter.LowestSupportedApiLevel
+import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS
+import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS_DEFAULT_VALUE
+import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES
+import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
+import org.oppia.android.util.platformparameter.NpsSurveyGracePeriodInDays
+import org.oppia.android.util.platformparameter.NpsSurveyMinimumAggregateLearningTimeInATopicInMinutes
import org.oppia.android.util.platformparameter.OPTIONAL_APP_UPDATE_VERSION_CODE
import org.oppia.android.util.platformparameter.OptionalAppUpdateVersionCode
import org.oppia.android.util.platformparameter.PERFORMANCE_METRICS_COLLECTION_HIGH_FREQUENCY_TIME_INTERVAL_IN_MINUTES
@@ -263,4 +269,28 @@ class PlatformParameterAlphaModule {
LOWEST_SUPPORTED_API_LEVEL_DEFAULT_VALUE
)
}
+
+ @Provides
+ @NpsSurveyGracePeriodInDays
+ fun provideNpsSurveyGracePeriodInDays(
+ platformParameterSingleton: PlatformParameterSingleton
+ ): PlatformParameterValue {
+ return platformParameterSingleton.getIntegerPlatformParameter(
+ NPS_SURVEY_GRACE_PERIOD_IN_DAYS
+ ) ?: PlatformParameterValue.createDefaultParameter(
+ NPS_SURVEY_GRACE_PERIOD_IN_DAYS_DEFAULT_VALUE
+ )
+ }
+
+ @Provides
+ @NpsSurveyMinimumAggregateLearningTimeInATopicInMinutes
+ fun provideNpsSurveyMinimumAggregateLearningTimeInATopicInMinutes(
+ platformParameterSingleton: PlatformParameterSingleton
+ ): PlatformParameterValue {
+ return platformParameterSingleton.getIntegerPlatformParameter(
+ NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES
+ ) ?: PlatformParameterValue.createDefaultParameter(
+ NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
+ )
+ }
}
diff --git a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt
index 53d9d5b0fb0..c2a211472fc 100644
--- a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt
+++ b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt
@@ -43,7 +43,7 @@ import org.oppia.android.util.platformparameter.LowestSupportedApiLevel
import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS
import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS_DEFAULT_VALUE
import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES
-import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VAL
+import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
import org.oppia.android.util.platformparameter.NpsSurveyGracePeriodInDays
import org.oppia.android.util.platformparameter.NpsSurveyMinimumAggregateLearningTimeInATopicInMinutes
import org.oppia.android.util.platformparameter.OPTIONAL_APP_UPDATE_VERSION_CODE
@@ -295,7 +295,7 @@ class PlatformParameterModule {
return platformParameterSingleton.getIntegerPlatformParameter(
NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES
) ?: PlatformParameterValue.createDefaultParameter(
- NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VAL
+ NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
)
}
}
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/survey/BUILD.bazel
index 0d2409f6857..d328aa24c0c 100644
--- a/domain/src/main/java/org/oppia/android/domain/survey/BUILD.bazel
+++ b/domain/src/main/java/org/oppia/android/domain/survey/BUILD.bazel
@@ -2,6 +2,7 @@
Library for providing survey functionality in the app.
"""
+load("@dagger//:workspace_defs.bzl", "dagger_rules")
load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library")
kt_android_library(
@@ -18,3 +19,45 @@ kt_android_library(
"//utility/src/main/java/org/oppia/android/util/system:oppia_clock",
],
)
+
+kt_android_library(
+ name = "survey_controller",
+ srcs = [
+ "SurveyController.kt",
+ ],
+ visibility = ["//:oppia_api_visibility"],
+ deps = [
+ ":survey_progress_controller",
+ ],
+)
+
+kt_android_library(
+ name = "survey_progress_controller",
+ srcs = [
+ "SurveyProgressController.kt",
+ ],
+ visibility = ["//:oppia_api_visibility"],
+ deps = [
+ ":survey_progress",
+ "//domain",
+ "//domain/src/main/java/org/oppia/android/domain/profile:profile_management_controller",
+ "//third_party:javax_inject_javax_inject",
+ "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale",
+ "//utility/src/main/java/org/oppia/android/util/system:oppia_clock",
+ ],
+)
+
+kt_android_library(
+ name = "survey_progress",
+ srcs = [
+ "SurveyProgress.kt",
+ "SurveyQuestionDeck.kt",
+ "SurveyQuestionGraph.kt",
+ ],
+ visibility = ["//:oppia_api_visibility"],
+ deps = [
+ "//model/src/main/proto:survey_java_proto_lite",
+ ],
+)
+
+dagger_rules()
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt
new file mode 100644
index 00000000000..06a11a82107
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt
@@ -0,0 +1,126 @@
+package org.oppia.android.domain.survey
+
+import org.oppia.android.app.model.Survey
+import org.oppia.android.app.model.SurveyQuestion
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProvider
+import org.oppia.android.util.data.DataProviders
+import org.oppia.android.util.data.DataProviders.Companion.combineWith
+import org.oppia.android.util.data.DataProviders.Companion.transform
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val CREATE_SURVEY_PROVIDER_ID = "create_survey_provider_id"
+private const val START_SURVEY_SESSION_PROVIDER_ID = "start_survey_session_provider_id"
+private const val CREATE_QUESTIONS_LIST_PROVIDER_ID = "create_questions_list_provider_id"
+
+/**
+ * Controller for creating and retrieving all attributes of a survey.
+ *
+ * Only one survey is shown at a time, and its progress is controlled by the
+ * [SurveyProgressController].
+ */
+@Singleton
+class SurveyController @Inject constructor(
+ private val dataProviders: DataProviders,
+ private val surveyProgressController: SurveyProgressController,
+ private val exceptionsController: ExceptionsController
+) {
+ private val surveyId = UUID.randomUUID().toString()
+
+ /**
+ * Starts a new survey session with a list of questions.
+ *
+ * @property mandatoryQuestionNames a list of uniques names of the questions that will be
+ * generated for this survey. Callers should be aware that the order of questions is important as
+ * the list will be indexed and displayed in the provided order.
+ * @return a [DataProvider] indicating whether the session start was successful
+ */
+ fun startSurveySession(
+ mandatoryQuestionNames: List,
+ showOptionalQuestion: Boolean = true
+ ): DataProvider {
+ return try {
+ val createSurveyDataProvider =
+ createSurvey(mandatoryQuestionNames, showOptionalQuestion)
+ val questionsListDataProvider =
+ createSurveyDataProvider.transform(CREATE_QUESTIONS_LIST_PROVIDER_ID) { survey ->
+ if (survey.hasOptionalQuestion()) {
+ survey.mandatoryQuestionsList + survey.optionalQuestion
+ } else survey.mandatoryQuestionsList
+ }
+
+ val beginSessionDataProvider =
+ surveyProgressController.beginSurveySession(questionsListDataProvider)
+
+ beginSessionDataProvider.combineWith(
+ createSurveyDataProvider, START_SURVEY_SESSION_PROVIDER_ID
+ ) { sessionResult, _ -> sessionResult }
+ } catch (e: Exception) {
+ exceptionsController.logNonFatalException(e)
+ dataProviders.createInMemoryDataProviderAsync(START_SURVEY_SESSION_PROVIDER_ID) {
+ AsyncResult.Failure(e)
+ }
+ }
+ }
+
+ private fun createSurvey(
+ mandatoryQuestionNames: List,
+ showOptionalQuestion: Boolean
+ ): DataProvider {
+ val mandatoryQuestionsList = mandatoryQuestionNames.mapIndexed { index, questionName ->
+ createSurveyQuestion(index.toString(), questionName)
+ }
+ // The questionId corresponds to the order of the questions in list, so the optional question
+ // will always come at the end of the list.
+ val surveyBuilder = Survey.newBuilder()
+ .setSurveyId(surveyId)
+ .addAllMandatoryQuestions(mandatoryQuestionsList)
+
+ if (showOptionalQuestion) {
+ surveyBuilder.optionalQuestion =
+ createDefaultFeedbackQuestion(mandatoryQuestionsList.size.toString())
+ }
+
+ return dataProviders.createInMemoryDataProvider(CREATE_SURVEY_PROVIDER_ID) {
+ surveyBuilder.build()
+ }
+ }
+
+ private fun createSurveyQuestion(
+ questionId: String,
+ questionName: SurveyQuestionName
+ ): SurveyQuestion {
+ return SurveyQuestion.newBuilder()
+ .setQuestionId(questionId)
+ .setQuestionName(questionName)
+ .build()
+ }
+
+ private fun createDefaultFeedbackQuestion(
+ questionId: String
+ ): SurveyQuestion {
+ return SurveyQuestion.newBuilder()
+ .setQuestionId(questionId)
+ .setQuestionName(SurveyQuestionName.PROMOTER_FEEDBACK)
+ .setFreeFormText(true)
+ .build()
+ }
+
+ /**
+ * Finishes the most recent session started by [startSurveySession].
+ *
+ * This method should only be called if there is an active session, otherwise the
+ * resulting provider will fail. Note that this doesn't actually need to be called between
+ * sessions unless the caller wants to ensure other providers monitored from
+ * [SurveyProgressController] are reset to a proper out-of-session state.
+ *
+ * Note that the returned provider monitors the long-term stopping state of survey sessions and
+ * will be reset to 'pending' when a session is currently active, or before any session has
+ * started.
+ */
+ fun stopSurveySession(): DataProvider = surveyProgressController.endSurveySession()
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyGatingController.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyGatingController.kt
index b7e8ba6fb65..fcb790a0245 100644
--- a/domain/src/main/java/org/oppia/android/domain/survey/SurveyGatingController.kt
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyGatingController.kt
@@ -38,8 +38,8 @@ class SurveyGatingController @Inject constructor(
)
/**
- * Returns a data provider containing the outcome of gating, which will be used by callers to
- * determine if a survey can be shown.
+ * Returns a data provider containing a boolean outcome of gating, which informs callers whether
+ * a survey can be shown.
*/
fun maybeShowSurvey(profileId: ProfileId, topicId: String): DataProvider {
val lastShownDateProvider = retrieveSurveyLastShownDate(profileId)
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgress.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgress.kt
new file mode 100644
index 00000000000..e25cc912303
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgress.kt
@@ -0,0 +1,112 @@
+package org.oppia.android.domain.survey
+
+import org.oppia.android.app.model.SurveyQuestion
+
+/**
+ * Private class that encapsulates the mutable state of a survey progress controller.
+ * This class is not thread-safe, so owning classes should ensure synchronized access.
+ */
+class SurveyProgress {
+ var surveyStage: SurveyStage = SurveyStage.NOT_IN_SURVEY_SESSION
+ private var questionsList: List = mutableListOf()
+ private var isTopQuestionCompleted: Boolean = false
+ val questionGraph: SurveyQuestionGraph by lazy {
+ SurveyQuestionGraph(questionsList as MutableList)
+ }
+ val questionDeck: SurveyQuestionDeck by lazy {
+ SurveyQuestionDeck(getTotalQuestionCount(), getInitialQuestion(), this::isTopQuestionTerminal)
+ }
+
+ /** Initialize the survey with the specified list of questions. */
+ fun initialize(questionsList: List) {
+ advancePlayStageTo(SurveyStage.VIEWING_SURVEY_QUESTION)
+ this.questionsList = questionsList
+ isTopQuestionCompleted = false
+ }
+
+ /** Returns the index of the current question being viewed. */
+ private fun getCurrentQuestionIndex(): Int {
+ return questionDeck.getTopQuestionIndex()
+ }
+
+ /** Returns the first question in the list. */
+ private fun getInitialQuestion(): SurveyQuestion = questionsList.first()
+
+ /** Returns the number of questions in the survey. */
+ fun getTotalQuestionCount(): Int {
+ return questionsList.size
+ }
+
+ /** Update the question at the current position of the deck. */
+ fun refreshDeck() {
+ questionDeck.updateDeck(questionGraph.getQuestion(getCurrentQuestionIndex()))
+ }
+
+ /**
+ * Advances the current play stage to the specified stage, verifying that the transition is correct.
+ *
+ * Calling code should prevent this method from failing by checking state ahead of calling this method and providing
+ * more useful errors to UI calling code since errors thrown by this method will be more obscure. This method aims to
+ * ensure the internal state of the controller remains correct. This method is not meant to be covered in unit tests
+ * since none of the failures here should ever be exposed to controller callers.
+ */
+ fun advancePlayStageTo(nextStage: SurveyStage) {
+ when (nextStage) {
+ SurveyStage.NOT_IN_SURVEY_SESSION -> {
+ // All transitions to NOT_IN_SURVEY_SESSION are valid except those originating from itself.
+ check(surveyStage != SurveyStage.NOT_IN_SURVEY_SESSION) {
+ "Cannot transition to NOT_IN_TRAINING_SESSION from NOT_IN_TRAINING_SESSION"
+ }
+ surveyStage = nextStage
+ }
+ SurveyStage.LOADING_SURVEY_SESSION -> {
+ // A session can only start being loaded when not previously in a session.
+ check(surveyStage == SurveyStage.NOT_IN_SURVEY_SESSION) {
+ "Cannot transition to LOADING_SURVEY_SESSION from $surveyStage"
+ }
+ surveyStage = nextStage
+ }
+ SurveyStage.VIEWING_SURVEY_QUESTION -> {
+ // A question can be viewed after loading a survey session, after viewing another question,
+ // or after submitting an answer. It cannot be viewed without a loaded session.
+ check(
+ surveyStage == SurveyStage.LOADING_SURVEY_SESSION ||
+ surveyStage == SurveyStage.VIEWING_SURVEY_QUESTION ||
+ surveyStage == SurveyStage.SUBMITTING_ANSWER
+ ) {
+ "Cannot transition to VIEWING_SURVEY_QUESTION from $surveyStage"
+ }
+ surveyStage = nextStage
+ }
+ SurveyStage.SUBMITTING_ANSWER -> {
+ // An answer can only be submitted after viewing a question.
+ check(surveyStage == SurveyStage.VIEWING_SURVEY_QUESTION) {
+ "Cannot transition to SUBMITTING_ANSWER from $surveyStage"
+ }
+ surveyStage = nextStage
+ }
+ }
+ }
+
+ private fun isTopQuestionTerminal(
+ @Suppress("UNUSED_PARAMETER") surveyQuestion: SurveyQuestion
+ ): Boolean {
+ return questionDeck.isCurrentQuestionTopOfDeck() &&
+ getCurrentQuestionIndex() == getTotalQuestionCount().minus(1)
+ }
+
+ /** Different stages in which the progress controller can exist. */
+ enum class SurveyStage {
+ /** No session is currently ongoing. */
+ NOT_IN_SURVEY_SESSION,
+
+ /** A survey is currently being prepared. */
+ LOADING_SURVEY_SESSION,
+
+ /** The controller is currently viewing a SurveyQuestion. */
+ VIEWING_SURVEY_QUESTION,
+
+ /** The controller is in the process of submitting an answer. */
+ SUBMITTING_ANSWER
+ }
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt
new file mode 100644
index 00000000000..c38bcf713b6
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt
@@ -0,0 +1,667 @@
+package org.oppia.android.domain.survey
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.oppia.android.app.model.EphemeralSurveyQuestion
+import org.oppia.android.app.model.SelectedAnswerDatabase
+import org.oppia.android.app.model.SurveyQuestion
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.data.persistence.PersistentCacheStore
+import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProvider
+import org.oppia.android.util.data.DataProviders
+import org.oppia.android.util.data.DataProviders.Companion.combineWith
+import org.oppia.android.util.data.DataProviders.Companion.transformAsync
+import org.oppia.android.util.data.DataProviders.Companion.transformNested
+import org.oppia.android.util.threading.BackgroundDispatcher
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val BEGIN_SESSION_RESULT_PROVIDER_ID = "SurveyProgressController.begin_session_result"
+private const val EMPTY_QUESTIONS_LIST_DATA_PROVIDER_ID =
+ "SurveyProgressController.create_empty_questions_list_data_provider_id"
+private const val MONITORED_QUESTION_LIST_PROVIDER_ID = "" +
+ "SurveyProgressController.monitored_question_list"
+private const val CURRENT_QUESTION_PROVIDER_ID =
+ "SurveyProgressController.current_question"
+private const val EPHEMERAL_QUESTION_FROM_UPDATED_QUESTION_LIST_PROVIDER_ID =
+ "SurveyProgressController.ephemeral_question_from_updated_question_list"
+private const val MOVE_TO_NEXT_QUESTION_RESULT_PROVIDER_ID =
+ "SurveyProgressController.move_to_next_question_result"
+private const val MOVE_TO_PREVIOUS_QUESTION_RESULT_PROVIDER_ID =
+ "SurveyProgressController.move_to_previous_question_result"
+private const val SUBMIT_ANSWER_RESULT_PROVIDER_ID =
+ "SurveyProgressController.submit_answer_result"
+private const val END_SESSION_RESULT_PROVIDER_ID = "SurveyProgressController.end_session_result"
+private const val RETRIEVE_RESPONSE_DATA_PROVIDER_ID =
+ "retrieve_response_provider_id"
+private const val AUGMENTED_QUESTION_PROVIDER_ID =
+ "SurveyProgressController.augmented_question"
+
+/**
+ * A default session ID to be used before a session has been initialized.
+ *
+ * This session ID will never match, so messages that are received with this ID will never be
+ * processed.
+ */
+private const val DEFAULT_SESSION_ID = "default_session_id"
+
+/** The name of the cache used to hold selected survey responses ephemerally. */
+private const val CACHE_NAME = "survey_responses_database"
+
+/** Controller for tracking the non-persisted progress of a survey. */
+@Singleton
+class SurveyProgressController @Inject constructor(
+ private val dataProviders: DataProviders,
+ private val exceptionsController: ExceptionsController,
+ @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher,
+ cacheStoreFactory: PersistentCacheStore.Factory
+) {
+ private var mostRecentSessionId: String? = null
+ private val activeSessionId: String
+ get() = mostRecentSessionId ?: DEFAULT_SESSION_ID
+
+ private var mostRecentEphemeralQuestionFlow =
+ createAsyncResultStateFlow(
+ AsyncResult.Failure(IllegalStateException("Survey is not yet initialized."))
+ )
+
+ private var mostRecentCommandQueue: SendChannel>? = null
+
+ private val monitoredQuestionListDataProvider: DataProviders.NestedTransformedDataProvider =
+ createCurrentQuestionDataProvider(createEmptyQuestionsListDataProvider())
+
+ private val answerDataStore =
+ cacheStoreFactory.create(CACHE_NAME, SelectedAnswerDatabase.getDefaultInstance())
+
+ /**
+ * Statuses correspond to the exceptions such that if the deferred contains an error state,
+ * a corresponding exception will be passed to a failed AsyncResult.
+ */
+ private enum class RecordResponseActionStatus {
+ /** Corresponds to a successful AsyncResult. */
+ SUCCESS,
+
+ /** Corresponds to a failed saving attempt. */
+ FAILED_TO_SAVE_RESPONSE
+ }
+
+ /**
+ * Begins a survey session based on a set of questions and returns a [DataProvider] indicating
+ * whether the start was successful.
+ */
+ fun beginSurveySession(
+ questionsListDataProvider: DataProvider>
+ ): DataProvider {
+ val ephemeralQuestionFlow = createAsyncResultStateFlow()
+ val sessionId = UUID.randomUUID().toString().also {
+ mostRecentSessionId = it
+ mostRecentEphemeralQuestionFlow = ephemeralQuestionFlow
+ mostRecentCommandQueue = createControllerCommandActor()
+ }
+ monitoredQuestionListDataProvider.setBaseDataProvider(questionsListDataProvider) {
+ maybeSendReceiveQuestionListEvent(mostRecentCommandQueue, it)
+ }
+ val beginSessionResultFlow = createAsyncResultStateFlow()
+ val initializeMessage: ControllerMessage<*> =
+ ControllerMessage.InitializeController(
+ ephemeralQuestionFlow, sessionId, beginSessionResultFlow
+ )
+ sendCommandForOperation(initializeMessage) {
+ "Failed to schedule command for initializing the survey progress controller."
+ }
+ return beginSessionResultFlow.convertToSessionProvider(BEGIN_SESSION_RESULT_PROVIDER_ID)
+ }
+
+ /**
+ * Returns a [DataProvider] monitoring the [EphemeralSurveyQuestion] the user is currently
+ * viewing.
+ *
+ * This [DataProvider] may switch from a completed to a pending result during transient operations
+ * like submitting an answer via [submitAnswer]. Calling code should be made resilient to this by
+ * caching the current question object to display since it may disappear temporarily during answer
+ * submission. Calling code should persist this object across configuration changes if
+ * needed since it cannot rely on this [DataProvider] for immediate UI reconstitution after
+ * configuration changes.
+ *
+ * The underlying question returned by this function can only be changed by calls to
+ * [moveToNextQuestion], or [moveToPreviousQuestion].
+ *
+ * This method does not need to be called for the [EphemeralSurveyQuestion] to be computed;
+ * it's always computed eagerly by other state-changing methods regardless of whether there's an
+ * active subscription to this method's returned [DataProvider].
+ */
+ fun getCurrentQuestion(): DataProvider {
+ val ephemeralQuestionDataProvider =
+ mostRecentEphemeralQuestionFlow.convertToSessionProvider(CURRENT_QUESTION_PROVIDER_ID)
+
+ // Combine ephemeral question with the monitored question list to ensure that changes to the
+ // questions list trigger a recompute of the ephemeral question.
+ val questionsListDataProvider = monitoredQuestionListDataProvider.combineWith(
+ ephemeralQuestionDataProvider, EPHEMERAL_QUESTION_FROM_UPDATED_QUESTION_LIST_PROVIDER_ID
+ ) { _, currentQuestion ->
+ currentQuestion
+ }
+ val previousAnswerProvider =
+ questionsListDataProvider.transformAsync(
+ RETRIEVE_RESPONSE_DATA_PROVIDER_ID
+ ) { ephemeralQuestion ->
+ return@transformAsync AsyncResult.Success(
+ retrieveSelectedAnswer(ephemeralQuestion.question.questionId.toString())
+ )
+ }
+ return previousAnswerProvider.combineWith(
+ questionsListDataProvider, AUGMENTED_QUESTION_PROVIDER_ID
+ ) { previousSelectedAnswer, ephemeralQuestion ->
+ return@combineWith if (previousSelectedAnswer != SurveySelectedAnswer.getDefaultInstance()) {
+ augmentEphemeralQuestion(previousSelectedAnswer, ephemeralQuestion)
+ } else ephemeralQuestion
+ }
+ }
+
+ /**
+ * Submits an answer to the current question and returns how the UI should respond.
+ *
+ * If the app undergoes a configuration change, calling code should rely on the [DataProvider]
+ * from [getCurrentQuestion] to know whether a current answer is pending. That [DataProvider] will
+ * have its state changed to pending during answer submission.
+ *
+ * No assumptions should be made about the completion order of the returned [DataProvider] vs. the
+ * [DataProvider] from [getCurrentQuestion].
+ */
+ fun submitAnswer(selectedAnswer: SurveySelectedAnswer): DataProvider {
+ val submitResultFlow = createAsyncResultStateFlow()
+ val message = ControllerMessage.SubmitAnswer(selectedAnswer, activeSessionId, submitResultFlow)
+ sendCommandForOperation(message) { "Failed to schedule command for answer submission." }
+ return submitResultFlow.convertToSessionProvider(SUBMIT_ANSWER_RESULT_PROVIDER_ID)
+ }
+
+ /**
+ * Navigates to the next question in the survey. Calling code is responsible for ensuring this
+ * method is only called when it's possible to navigate forward.
+ *
+ * @return a [DataProvider] indicating whether the movement to the next question was successful,
+ * or a failure if question navigation was attempted at an invalid time (such as if the
+ * current question is pending or terminal). It's recommended that calling code only listen
+ * to this result for failures, and instead rely on [getCurrentQuestion] for observing a
+ * successful transition to another question.
+ */
+ fun moveToNextQuestion(): DataProvider {
+ val moveResultFlow = createAsyncResultStateFlow()
+ val message = ControllerMessage.MoveToNextQuestion(activeSessionId, moveResultFlow)
+ sendCommandForOperation(message) {
+ "Failed to schedule command for moving to the next question."
+ }
+ return moveResultFlow.convertToSessionProvider(MOVE_TO_NEXT_QUESTION_RESULT_PROVIDER_ID)
+ }
+
+ /**
+ * Navigates to the previous question in the survey. If the user is currently on the initial
+ * question, this method will throw an exception. Calling code is responsible for ensuring this
+ * method is only called when it's possible to navigate backward.
+ *
+ * @return a [DataProvider] indicating whether the movement to the previous question was
+ * successful, or a failure if question navigation was attempted at an invalid time
+ * (such as if the user is viewing the first question in the survey). It's recommended that
+ * calling code only listen to this result for failures, and instead rely on
+ * [getCurrentQuestion] for observing a successful transition to another question.
+ */
+ fun moveToPreviousQuestion(): DataProvider {
+ val moveResultFlow = createAsyncResultStateFlow()
+ val message = ControllerMessage.MoveToPreviousQuestion(activeSessionId, moveResultFlow)
+ sendCommandForOperation(message) {
+ "Failed to schedule command for moving to the previous question."
+ }
+ return moveResultFlow.convertToSessionProvider(MOVE_TO_PREVIOUS_QUESTION_RESULT_PROVIDER_ID)
+ }
+
+ /**
+ * Ends the current survey session and returns a [DataProvider] that indicates whether it was
+ * successfully ended.
+ *
+ * This method does not actually need to be called when a session is over. Calling it ensures all
+ * other [DataProvider]s reset to a correct out-of-session state, but subsequent calls to
+ * [beginSurveySession] will reset the session.
+ */
+ fun endSurveySession(): DataProvider {
+ // Reset the base questions list provider so that the ephemeral question has no question list to
+ // reference (since the session finished).
+ monitoredQuestionListDataProvider.setBaseDataProvider(createEmptyQuestionsListDataProvider()) {
+ maybeSendReceiveQuestionListEvent(commandQueue = null, it)
+ }
+ val endSessionResultFlow = createAsyncResultStateFlow()
+ val message = ControllerMessage.FinishSurveySession(
+ activeSessionId,
+ endSessionResultFlow
+ )
+ sendCommandForOperation(message) {
+ "Failed to schedule command for finishing the survey session."
+ }
+ return endSessionResultFlow.convertToSessionProvider(END_SESSION_RESULT_PROVIDER_ID)
+ }
+
+ private fun createCurrentQuestionDataProvider(
+ questionsListDataProvider: DataProvider>
+ ): DataProviders.NestedTransformedDataProvider {
+ return questionsListDataProvider.transformNested(MONITORED_QUESTION_LIST_PROVIDER_ID) {
+ maybeSendReceiveQuestionListEvent(commandQueue = null, it)
+ }
+ }
+
+ /** Returns a [DataProvider] that always provides an empty list of [SurveyQuestion]s. */
+ private fun createEmptyQuestionsListDataProvider(): DataProvider> {
+ return dataProviders.createInMemoryDataProvider(EMPTY_QUESTIONS_LIST_DATA_PROVIDER_ID) {
+ listOf()
+ }
+ }
+
+ private fun createControllerCommandActor(): SendChannel> {
+ lateinit var controllerState: ControllerState
+
+ @Suppress("JoinDeclarationAndAssignment") // Warning is incorrect in this case.
+ lateinit var commandQueue: SendChannel>
+ commandQueue = CoroutineScope(
+ backgroundCoroutineDispatcher
+ ).actor(capacity = Channel.UNLIMITED) {
+ for (message in channel) {
+ when (message) {
+ is ControllerMessage.InitializeController -> {
+ controllerState = ControllerState(
+ SurveyProgress(),
+ message.sessionId,
+ message.ephemeralQuestionFlow,
+ commandQueue
+ ).also {
+ it.beginSurveySessionImpl(message.callbackFlow)
+ }
+ }
+ is ControllerMessage.MoveToNextQuestion ->
+ controllerState.moveToNextQuestionImpl(message.callbackFlow)
+ is ControllerMessage.MoveToPreviousQuestion ->
+ controllerState.moveToPreviousQuestionImpl(message.callbackFlow)
+ is ControllerMessage.RecomputeQuestionAndNotify ->
+ controllerState.recomputeCurrentQuestionAndNotifyImpl()
+ is ControllerMessage.SubmitAnswer ->
+ controllerState.submitAnswerImpl(message.callbackFlow, message.selectedAnswer)
+ is ControllerMessage.ReceiveQuestionList ->
+ controllerState.handleUpdatedQuestionsList(message.questionsList)
+ is ControllerMessage.FinishSurveySession -> {
+ try {
+ controllerState.completeSurveyImpl(
+ message.callbackFlow
+ )
+ } finally {
+ // Ensure the actor ends since the session requires no further message processing.
+ break
+ }
+ }
+ }
+ }
+ }
+ return commandQueue
+ }
+
+ private fun sendCommandForOperation(
+ message: ControllerMessage,
+ lazyFailureMessage: () -> String
+ ) {
+ // TODO(#4119): Switch this to use trySend(), instead, which is much cleaner and doesn't require
+ // catching an exception.
+ val flowResult: AsyncResult = try {
+ val commandQueue = mostRecentCommandQueue
+ when {
+ commandQueue == null ->
+ AsyncResult.Failure(IllegalStateException("Session isn't initialized yet."))
+ !commandQueue.offer(message) ->
+ AsyncResult.Failure(IllegalStateException(lazyFailureMessage()))
+ // Ensure that the result is first reset since there will be a delay before the message is
+ // processed (if there's a flow).
+ else -> AsyncResult.Pending()
+ }
+ } catch (e: Exception) {
+ AsyncResult.Failure(e)
+ }
+ // This must be assigned separately since flowResult should always be calculated, even if
+ // there's no callbackFlow to report it.
+ message.callbackFlow?.value = flowResult
+ }
+
+ private suspend fun maybeSendReceiveQuestionListEvent(
+ commandQueue: SendChannel>?,
+ questionsList: List
+ ): AsyncResult {
+ // Only send the message if there's a queue to send it to (which there might not be for cases
+ // where a session isn't active).
+ commandQueue?.send(ControllerMessage.ReceiveQuestionList(questionsList, activeSessionId))
+ return AsyncResult.Success(null)
+ }
+
+ private suspend fun ControllerState.beginSurveySessionImpl(
+ beginSessionResultFlow: MutableStateFlow>
+ ) {
+ tryOperation(beginSessionResultFlow) {
+ recomputeCurrentQuestionAndNotifyAsync()
+ answerDataStore.clearCacheAsync()
+ progress.advancePlayStageTo(SurveyProgress.SurveyStage.LOADING_SURVEY_SESSION)
+ }
+ }
+
+ private suspend fun ControllerState.completeSurveyImpl(
+ endSessionResultFlow: MutableStateFlow>
+ ) {
+ checkNotNull(this) { "Cannot stop a survey session which wasn't started." }
+ tryOperation(endSessionResultFlow) {
+ answerDataStore.clearCacheAsync()
+ progress.advancePlayStageTo(SurveyProgress.SurveyStage.NOT_IN_SURVEY_SESSION)
+ }
+ }
+
+ private suspend fun ControllerState.submitAnswerImpl(
+ submitAnswerResultFlow: MutableStateFlow>,
+ selectedAnswer: SurveySelectedAnswer
+ ) {
+ tryOperation(submitAnswerResultFlow) {
+ check(progress.surveyStage != SurveyProgress.SurveyStage.SUBMITTING_ANSWER) {
+ "Cannot submit an answer while another answer is pending."
+ }
+
+ val currentQuestionId = progress.questionDeck.getTopQuestionIndex()
+ if (selectedAnswer.questionName == SurveyQuestionName.NPS) {
+ // compute the feedback question before navigating to it
+ progress.questionGraph.computeFeedbackQuestion(
+ currentQuestionId + 1,
+ selectedAnswer.npsScore
+ )
+ }
+
+ saveSelectedAnswer(currentQuestionId.toString(), selectedAnswer)
+
+ if (!progress.questionDeck.isCurrentQuestionTerminal()) {
+ moveToNextQuestion()
+ }
+ }
+ }
+
+ private fun saveSelectedAnswer(questionId: String, answer: SurveySelectedAnswer) {
+ val deferred = recordSelectedAnswerAsync(questionId, answer)
+
+ deferred.invokeOnCompletion {
+ if (it == null) {
+ deferred.getCompleted()
+ } else {
+ RecordResponseActionStatus.FAILED_TO_SAVE_RESPONSE
+ }
+ }
+ }
+
+ private fun recordSelectedAnswerAsync(
+ questionId: String,
+ answer: SurveySelectedAnswer
+ ): Deferred {
+ return answerDataStore.storeDataWithCustomChannelAsync(
+ updateInMemoryCache = true
+ ) { answerDatabase ->
+ answerDatabase.toBuilder().apply {
+ putSelectedAnswer(questionId, answer)
+ }.build() to RecordResponseActionStatus.SUCCESS
+ }
+ }
+
+ private suspend fun retrieveSelectedAnswer(questionId: String): SurveySelectedAnswer {
+ val answerDatabase = answerDataStore.readDataAsync().await()
+ return answerDatabase.selectedAnswerMap[questionId] ?: SurveySelectedAnswer.getDefaultInstance()
+ }
+
+ private suspend fun ControllerState.moveToNextQuestionImpl(
+ moveToNextQuestionResultFlow: MutableStateFlow>
+ ) {
+ tryOperation(moveToNextQuestionResultFlow) {
+ check(progress.surveyStage != SurveyProgress.SurveyStage.SUBMITTING_ANSWER) {
+ "Cannot navigate to a next question if an answer submission is pending."
+ }
+ progress.questionDeck.navigateToNextQuestion()
+ progress.refreshDeck()
+ }
+ }
+
+ private suspend fun ControllerState.moveToPreviousQuestionImpl(
+ moveToPreviousQuestionResultFlow: MutableStateFlow>
+ ) {
+ tryOperation(moveToPreviousQuestionResultFlow) {
+ check(progress.surveyStage != SurveyProgress.SurveyStage.LOADING_SURVEY_SESSION) {
+ "Cannot navigate to a previous question if a session is being loaded."
+ }
+ check(progress.surveyStage != SurveyProgress.SurveyStage.SUBMITTING_ANSWER) {
+ "Cannot navigate to a previous question if an answer submission is pending."
+ }
+ progress.questionDeck.navigateToPreviousQuestion()
+ progress.refreshDeck()
+ }
+ }
+
+ private fun createAsyncResultStateFlow(initialValue: AsyncResult = AsyncResult.Pending()) =
+ MutableStateFlow(initialValue)
+
+ private fun StateFlow>.convertToSessionProvider(
+ baseId: String
+ ): DataProvider = dataProviders.run {
+ convertAsyncToAutomaticDataProvider("${baseId}_$activeSessionId")
+ }
+
+ /**
+ * Represents a message that can be sent to [mostRecentCommandQueue] to process changes to
+ * [ControllerState] (since all changes must be synchronized).
+ *
+ * Messages are expected to be resolved serially (though their scheduling can occur across
+ * multiple threads, so order cannot be guaranteed until they're enqueued).
+ */
+ private sealed class ControllerMessage {
+ /**
+ * The session ID corresponding to this message (the message is expected to be ignored if it
+ * doesn't correspond to an active session).
+ */
+ abstract val sessionId: String
+
+ /**
+ * The [DataProvider]-tied [MutableStateFlow] that represents the result of the operation
+ * corresponding to this message, or ``null`` if the caller doesn't care about observing the
+ * result.
+ */
+ abstract val callbackFlow: MutableStateFlow>?
+
+ /** [ControllerMessage] for initializing a new survey session. */
+ data class InitializeController(
+ val ephemeralQuestionFlow: MutableStateFlow>,
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>
+ ) : ControllerMessage()
+
+ /** [ControllerMessage] for ending the current survey session. */
+ data class FinishSurveySession(
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>
+ ) : ControllerMessage()
+
+ /** [ControllerMessage] for submitting a new [SurveySelectedAnswer]. */
+ data class SubmitAnswer(
+ val selectedAnswer: SurveySelectedAnswer,
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>
+ ) : ControllerMessage()
+
+ /** [ControllerMessage] to move to the previous question in the survey. */
+ data class MoveToPreviousQuestion(
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>
+ ) : ControllerMessage()
+
+ /** [ControllerMessage] to move to the next question in the survey. */
+ data class MoveToNextQuestion(
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>
+ ) : ControllerMessage()
+
+ /**
+ * [ControllerMessage] which recomputes the current [EphemeralSurveyQuestion] and notifies
+ * subscribers of the [DataProvider] returned by [getCurrentQuestion] of the change.
+ * This is only used in cases where an external operation trigger changes that are only
+ * reflected when recomputing the question (e.g. an answer was changed).
+ */
+ data class RecomputeQuestionAndNotify(
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>? = null
+ ) : ControllerMessage()
+
+ /**
+ * [ControllerMessage] for finishing the initialization of the survey session by providing a
+ * list of [SurveyQuestion]s to display.
+ */
+ data class ReceiveQuestionList(
+ val questionsList: List,
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>? = null
+ ) : ControllerMessage()
+ }
+
+ private suspend fun ControllerState.tryOperation(
+ resultFlow: MutableStateFlow>,
+ operation: suspend ControllerState.() -> T
+ ) {
+ try {
+ resultFlow.emit(AsyncResult.Success(operation()))
+ recomputeCurrentQuestionAndNotifySync()
+ } catch (e: Exception) {
+ exceptionsController.logNonFatalException(e)
+ resultFlow.emit(AsyncResult.Failure(e))
+ }
+ }
+
+ private suspend fun ControllerState.handleUpdatedQuestionsList(
+ questionsList: List
+ ) {
+ // The questions list is possibly changed which may affect the computed ephemeral question.
+ if (!this.isQuestionsListInitialized || this.questionsList != questionsList) {
+ this.questionsList = questionsList
+ // Only notify if the questions list is different (otherwise an infinite notify loop might be
+ // started).
+ recomputeCurrentQuestionAndNotifySync()
+ }
+ }
+
+ /**
+ * Immediately recomputes the current question & notifies it's been changed.
+ *
+ * This should only be called when the caller can guarantee that the current [ControllerState] is
+ * correct and up-to-date (i.e. that this is being called via a direct call path from the actor).
+ *
+ * All other cases must use [recomputeCurrentQuestionAndNotifyAsync].
+ */
+ private suspend fun ControllerState.recomputeCurrentQuestionAndNotifySync() {
+ recomputeCurrentQuestionAndNotifyImpl()
+ }
+
+ /**
+ * Sends a message to recompute the current question & notify it's been changed.
+ *
+ * This must be used in cases when the current [ControllerState] may no longer be up-to-date.
+ */
+ private suspend fun ControllerState.recomputeCurrentQuestionAndNotifyAsync() {
+ commandQueue.send(ControllerMessage.RecomputeQuestionAndNotify(sessionId))
+ }
+
+ private suspend fun ControllerState.recomputeCurrentQuestionAndNotifyImpl() {
+ ephemeralQuestionFlow.emit(
+ if (isQuestionsListInitialized) {
+ // Only compute the ephemeral question if there's a questions list loaded (otherwise the
+ // controller is in a pending state).
+ retrieveCurrentQuestionAsync(questionsList)
+ } else AsyncResult.Pending()
+ )
+ }
+
+ private suspend fun ControllerState.retrieveCurrentQuestionAsync(
+ questionsList: List
+ ): AsyncResult {
+ return try {
+ when (progress.surveyStage) {
+ SurveyProgress.SurveyStage.NOT_IN_SURVEY_SESSION -> AsyncResult.Pending()
+ SurveyProgress.SurveyStage.LOADING_SURVEY_SESSION -> {
+ // If the survey hasn't yet been initialized, initialize it
+ // now that a list of questions is available.
+ initializeSurvey(questionsList)
+ progress.advancePlayStageTo(SurveyProgress.SurveyStage.VIEWING_SURVEY_QUESTION)
+ AsyncResult.Success(computeBaseCurrentEphemeralQuestion())
+ }
+ SurveyProgress.SurveyStage.VIEWING_SURVEY_QUESTION -> {
+ AsyncResult.Success(computeBaseCurrentEphemeralQuestion())
+ }
+ SurveyProgress.SurveyStage.SUBMITTING_ANSWER -> AsyncResult.Pending()
+ }
+ } catch (e: Exception) {
+ exceptionsController.logNonFatalException(e)
+ AsyncResult.Failure(e)
+ }
+ }
+
+ private fun ControllerState.initializeSurvey(questionsList: List) {
+ check(questionsList.isNotEmpty()) { "Cannot start a survey session with zero questions." }
+ progress.initialize(questionsList)
+ }
+
+ private fun ControllerState.computeBaseCurrentEphemeralQuestion(): EphemeralSurveyQuestion =
+ progress.questionDeck.getCurrentEphemeralQuestion()
+
+ /**
+ * Augments the specified [EphemeralSurveyQuestion] [AsyncResult] by attaching a previously
+ * selected answer to update the UI.
+ */
+ private fun augmentEphemeralQuestion(
+ previousAnswer: SurveySelectedAnswer,
+ ephemeralQuestion: EphemeralSurveyQuestion
+ ): EphemeralSurveyQuestion {
+ return ephemeralQuestion.toBuilder().apply {
+ selectedAnswer = previousAnswer
+ }.build()
+ }
+
+ /**
+ * Represents the current synchronized state of the controller.
+ *
+ * This object's instance is tied directly to a single training session, and it's not thread-safe
+ * so all access must be synchronized.
+ *
+ * @property progress the [SurveyProgress] corresponding to the session
+ * @property sessionId the GUID corresponding to the session
+ * @property ephemeralQuestionFlow the [MutableStateFlow] that the updated
+ * [EphemeralSurveyQuestion] is delivered to.
+ * @property commandQueue the actor command queue executing all messages that change this state
+ */
+ private class ControllerState(
+ val progress: SurveyProgress,
+ val sessionId: String,
+ val ephemeralQuestionFlow: MutableStateFlow>,
+ val commandQueue: SendChannel>
+ ) {
+ /**
+ * The list of [SurveyQuestion]s currently being played in the training session.
+ *
+ * Because this is updated based on [ControllerMessage.ReceiveQuestionList], it may not be
+ * initialized at the beginning of a session. Callers should check [isQuestionsListInitialized]
+ * prior to accessing this field.
+ */
+ lateinit var questionsList: List
+
+ /** Indicates whether [questionsList] is initialized with values. */
+ val isQuestionsListInitialized: Boolean
+ get() = ::questionsList.isInitialized
+ }
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt
new file mode 100644
index 00000000000..2b7de9d6aa5
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt
@@ -0,0 +1,94 @@
+package org.oppia.android.domain.survey
+
+import org.oppia.android.app.model.EphemeralSurveyQuestion
+import org.oppia.android.app.model.SurveyQuestion
+
+/**
+ * Tracks the dynamic behavior of the user through a survey session. This class
+ * treats the survey progress like a deck of cards to simplify forward/backward navigation.
+ */
+class SurveyQuestionDeck constructor(
+ private val totalQuestionCount: Int,
+ initialQuestion: SurveyQuestion,
+ private val isTopOfDeckTerminalChecker: (SurveyQuestion) -> Boolean
+) {
+ private var pendingTopQuestion = initialQuestion
+ private var viewedQuestionsCount: Int = 0
+ private var questionIndex: Int = 0
+
+ /** Sets this deck to a specific question. */
+ fun updateDeck(pendingTopQuestion: SurveyQuestion) {
+ this.pendingTopQuestion = pendingTopQuestion
+ }
+
+ /** Navigates to the previous question in the deck or fails if it is not possible. */
+ fun navigateToPreviousQuestion() {
+ check(!isCurrentQuestionInitial()) {
+ "Cannot navigate to previous question; at initial question."
+ }
+ questionIndex--
+ }
+
+ /** Navigates to the next question in the deck or fails if it is not possible. */
+ fun navigateToNextQuestion() {
+ check(!isCurrentQuestionTerminal()) {
+ "Cannot navigate to next question; at terminal question."
+ }
+ questionIndex++
+ viewedQuestionsCount++
+ }
+
+ /** Returns the index of the current selected question of the deck. */
+ fun getTopQuestionIndex(): Int = questionIndex
+
+ /** Returns whether this is the first question in the survey. */
+ private fun isCurrentQuestionInitial(): Boolean {
+ return questionIndex == 0
+ }
+
+ /** Returns the current [EphemeralSurveyQuestion] the learner is viewing. */
+ fun getCurrentEphemeralQuestion(): EphemeralSurveyQuestion {
+ return if (isCurrentQuestionTerminal()) {
+ getCurrentTerminalQuestion()
+ } else {
+ getCurrentPendingQuestion()
+ }
+ }
+
+ private fun getCurrentPendingQuestion(): EphemeralSurveyQuestion {
+ return EphemeralSurveyQuestion.newBuilder()
+ .setHasPreviousQuestion(!isCurrentQuestionInitial())
+ .setHasNextQuestion(!isCurrentQuestionTerminal())
+ .setQuestion(pendingTopQuestion)
+ .setPendingQuestion(true)
+ .setCurrentQuestionIndex(questionIndex)
+ .setTotalQuestionCount(totalQuestionCount)
+ .build()
+ }
+
+ private fun getCurrentTerminalQuestion(): EphemeralSurveyQuestion {
+ return EphemeralSurveyQuestion.newBuilder()
+ .setHasPreviousQuestion(!isCurrentQuestionInitial())
+ .setHasNextQuestion(false)
+ .setQuestion(pendingTopQuestion)
+ .setTerminalQuestion(true)
+ .setCurrentQuestionIndex(questionIndex)
+ .setTotalQuestionCount(totalQuestionCount)
+ .build()
+ }
+
+ /** Returns whether this is the most recent question in the survey. */
+ fun isCurrentQuestionTopOfDeck(): Boolean {
+ return questionIndex == viewedQuestionsCount
+ }
+
+ /** Returns whether this is the last question in the survey. */
+ fun isCurrentQuestionTerminal(): Boolean {
+ return isCurrentQuestionTopOfDeck() && isTopOfDeckTerminal()
+ }
+
+ /** Returns whether the most recent card on the deck is terminal. */
+ private fun isTopOfDeckTerminal(): Boolean {
+ return isTopOfDeckTerminalChecker(pendingTopQuestion)
+ }
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionGraph.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionGraph.kt
new file mode 100644
index 00000000000..727b30a7c14
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionGraph.kt
@@ -0,0 +1,27 @@
+package org.oppia.android.domain.survey
+
+import org.oppia.android.app.model.SurveyQuestion
+import org.oppia.android.app.model.SurveyQuestionName
+
+/** Computes the next question in the deck and provides lookup access for [SurveyQuestion]s. */
+class SurveyQuestionGraph constructor(
+ private var questionList: MutableList
+) {
+ /** Returns the [SurveyQuestion] corresponding to the provided index. */
+ fun getQuestion(questionIndex: Int): SurveyQuestion = questionList[questionIndex]
+
+ /** Decides which feedback question should be shown based on a user's nps score selection. */
+ fun computeFeedbackQuestion(index: Int, npsScore: Int) {
+ when (npsScore) {
+ in 9..10 -> questionList[index] = createQuestion(SurveyQuestionName.PROMOTER_FEEDBACK)
+ in 7..8 -> questionList[index] = createQuestion(SurveyQuestionName.PASSIVE_FEEDBACK)
+ else -> questionList[index] = createQuestion(SurveyQuestionName.DETRACTOR_FEEDBACK)
+ }
+ }
+
+ private fun createQuestion(questionName: SurveyQuestionName): SurveyQuestion {
+ return SurveyQuestion.newBuilder()
+ .setQuestionName(questionName)
+ .build()
+ }
+}
diff --git a/domain/src/test/java/org/oppia/android/domain/audio/AudioPlayerControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/audio/AudioPlayerControllerTest.kt
index f56a812ade8..1730f091d1b 100644
--- a/domain/src/test/java/org/oppia/android/domain/audio/AudioPlayerControllerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/audio/AudioPlayerControllerTest.kt
@@ -873,7 +873,7 @@ class AudioPlayerControllerTest {
DragDropSortInputModule::class, ImageClickInputModule::class, RatioInputModule::class,
NumericExpressionInputModule::class, AlgebraicExpressionInputModule::class,
MathEquationInputModule::class, CachingTestModule::class, HintsAndSolutionProdModule::class,
- HintsAndSolutionConfigModule::class, LoggerModule::class, ExplorationProgressModule::class
+ HintsAndSolutionConfigModule::class, LoggerModule::class, ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : DataProvidersInjector {
diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt
index 1d416794286..cb189de92ba 100644
--- a/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt
@@ -1000,7 +1000,7 @@ class ExplorationCheckpointControllerTest {
AlgebraicExpressionInputModule::class, MathEquationInputModule::class,
RatioInputModule::class, ImageClickInputModule::class, InteractionsModule::class,
HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class,
- ExplorationProgressModule::class
+ ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : DataProvidersInjector {
diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt
index 76c90666235..462dd3f04ad 100644
--- a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt
@@ -89,16 +89,25 @@ class LearnerAnalyticsLoggerTest {
private const val DEFAULT_INITIAL_SESSION_ID = "e6eacc69-e636-3c90-ba29-32bf3dd17161"
}
- @Inject lateinit var learnerAnalyticsLogger: LearnerAnalyticsLogger
- @Inject lateinit var explorationDataController: ExplorationDataController
- @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory
- @Inject lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
-
- @Parameter lateinit var iid: String
- @Parameter lateinit var lid: String
- @Parameter lateinit var eid: String
- @Parameter lateinit var elid: String
+ @Inject
+ lateinit var learnerAnalyticsLogger: LearnerAnalyticsLogger
+ @Inject
+ lateinit var explorationDataController: ExplorationDataController
+ @Inject
+ lateinit var monitorFactory: DataProviderTestMonitor.Factory
+ @Inject
+ lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ @Parameter
+ lateinit var iid: String
+ @Parameter
+ lateinit var lid: String
+ @Parameter
+ lateinit var eid: String
+ @Parameter
+ lateinit var elid: String
private val learnerIdParameter: String? get() = lid.takeIf { it != "null" }
private val installIdParameter: String? get() = iid.takeIf { it != "null" }
@@ -1828,7 +1837,7 @@ class LearnerAnalyticsLoggerTest {
NumericExpressionInputModule::class, AlgebraicExpressionInputModule::class,
MathEquationInputModule::class, ImageClickInputModule::class, AssetModule::class,
HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class,
- CachingTestModule::class, ExplorationProgressModule::class
+ CachingTestModule::class, ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : DataProvidersInjector {
diff --git a/domain/src/test/java/org/oppia/android/domain/survey/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/survey/BUILD.bazel
index 5e8abd91fde..4c93d8d7794 100644
--- a/domain/src/test/java/org/oppia/android/domain/survey/BUILD.bazel
+++ b/domain/src/test/java/org/oppia/android/domain/survey/BUILD.bazel
@@ -33,4 +33,58 @@ oppia_android_test(
],
)
+oppia_android_test(
+ name = "SurveyControllerTest",
+ srcs = ["SurveyControllerTest.kt"],
+ custom_package = "org.oppia.android.domain.survey",
+ test_class = "org.oppia.android.domain.survey.SurveyControllerTest",
+ test_manifest = "//domain:test_manifest",
+ deps = [
+ ":dagger",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/survey:survey_controller",
+ "//testing",
+ "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/threading:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/time:test_module",
+ "//third_party:androidx_test_ext_junit",
+ "//third_party:com_google_truth_truth",
+ "//third_party:junit_junit",
+ "//third_party:org_robolectric_robolectric",
+ "//third_party:robolectric_android-all",
+ "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module",
+ "//utility/src/main/java/org/oppia/android/util/locale:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/logging:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/networking:debug_module",
+ ],
+)
+
+oppia_android_test(
+ name = "SurveyProgressControllerTest",
+ srcs = ["SurveyProgressControllerTest.kt"],
+ custom_package = "org.oppia.android.domain.survey",
+ test_class = "org.oppia.android.domain.survey.SurveyProgressControllerTest",
+ test_manifest = "//domain:test_manifest",
+ deps = [
+ ":dagger",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/survey:survey_controller",
+ "//testing",
+ "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/threading:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/time:test_module",
+ "//third_party:androidx_test_ext_junit",
+ "//third_party:com_google_truth_truth",
+ "//third_party:junit_junit",
+ "//third_party:org_robolectric_robolectric",
+ "//third_party:robolectric_android-all",
+ "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module",
+ "//utility/src/main/java/org/oppia/android/util/locale:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/logging:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/networking:debug_module",
+ ],
+)
+
dagger_rules()
diff --git a/domain/src/test/java/org/oppia/android/domain/survey/SurveyControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/survey/SurveyControllerTest.kt
new file mode 100644
index 00000000000..de0cf64e4bb
--- /dev/null
+++ b/domain/src/test/java/org/oppia/android/domain/survey/SurveyControllerTest.kt
@@ -0,0 +1,233 @@
+package org.oppia.android.domain.survey
+
+import android.app.Application
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import dagger.BindsInstance
+import dagger.Component
+import dagger.Module
+import dagger.Provides
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.domain.exploration.ExplorationProgressModule
+import org.oppia.android.domain.oppialogger.ApplicationIdSeed
+import org.oppia.android.domain.oppialogger.LogStorageModule
+import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
+import org.oppia.android.testing.FakeExceptionLogger
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.data.DataProviderTestMonitor
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.testing.time.FakeOppiaClockModule
+import org.oppia.android.util.caching.AssetModule
+import org.oppia.android.util.data.DataProvidersInjector
+import org.oppia.android.util.data.DataProvidersInjectorProvider
+import org.oppia.android.util.locale.LocaleProdModule
+import org.oppia.android.util.logging.EnableConsoleLog
+import org.oppia.android.util.logging.EnableFileLog
+import org.oppia.android.util.logging.GlobalLogLevel
+import org.oppia.android.util.logging.LogLevel
+import org.oppia.android.util.logging.SyncStatusModule
+import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.oppia.android.util.platformparameter.LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [SurveyController]. */
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(application = SurveyControllerTest.TestApplication::class)
+class SurveyControllerTest {
+ @Inject
+ lateinit var fakeExceptionLogger: FakeExceptionLogger
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ @Inject
+ lateinit var monitorFactory: DataProviderTestMonitor.Factory
+
+ @Inject
+ lateinit var surveyController: SurveyController
+
+ @Inject
+ lateinit var surveyProgressController: SurveyProgressController
+
+ val questions = listOf(
+ SurveyQuestionName.USER_TYPE,
+ SurveyQuestionName.MARKET_FIT,
+ SurveyQuestionName.NPS
+ )
+
+ @Before
+ fun setUp() {
+ setUpTestApplicationComponent()
+ }
+
+ @Test
+ fun testController_startSurveySession_succeeds() {
+ val surveyDataProvider =
+ surveyController.startSurveySession(questions)
+
+ monitorFactory.waitForNextSuccessfulResult(surveyDataProvider)
+ }
+
+ @Test
+ fun testController_startSurveySession_sessionStartsWithInitialQuestion() {
+ surveyController.startSurveySession(questions)
+
+ val result = surveyProgressController.getCurrentQuestion()
+ val ephemeralQuestion = monitorFactory.waitForNextSuccessfulResult(result)
+ assertThat(ephemeralQuestion.question.questionName).isEqualTo(SurveyQuestionName.USER_TYPE)
+ }
+
+ @Test
+ fun testStartSurveySession_withTwoQuestions_showOptionalQuestion_succeeds() {
+ val mandatoryQuestionNameList = listOf(SurveyQuestionName.NPS)
+ surveyController.startSurveySession(mandatoryQuestionNameList)
+
+ val result = surveyProgressController.getCurrentQuestion()
+ val ephemeralQuestion = monitorFactory.waitForNextSuccessfulResult(result)
+ assertThat(ephemeralQuestion.totalQuestionCount).isEqualTo(2)
+ }
+
+ @Test
+ fun testStartSurveySession_withTwoQuestions_dontShowOptionalQuestion_succeeds() {
+ val mandatoryQuestionNameList = listOf(SurveyQuestionName.MARKET_FIT, SurveyQuestionName.NPS)
+ surveyController.startSurveySession(
+ mandatoryQuestionNames = mandatoryQuestionNameList,
+ showOptionalQuestion = false
+ )
+
+ val result = surveyProgressController.getCurrentQuestion()
+ val ephemeralQuestion = monitorFactory.waitForNextSuccessfulResult(result)
+ assertThat(ephemeralQuestion.totalQuestionCount).isEqualTo(2)
+ }
+
+ @Test
+ fun testStartSurveySession_withOneQuestion_showOptionalQuestionOnly_succeeds() {
+ val mandatoryQuestionNameList = listOf()
+ surveyController.startSurveySession(
+ mandatoryQuestionNames = mandatoryQuestionNameList,
+ showOptionalQuestion = true
+ )
+
+ val result = surveyProgressController.getCurrentQuestion()
+ val ephemeralQuestion = monitorFactory.waitForNextSuccessfulResult(result)
+ assertThat(ephemeralQuestion.totalQuestionCount).isEqualTo(1)
+ assertThat(ephemeralQuestion.question.questionName)
+ .isEqualTo(SurveyQuestionName.PROMOTER_FEEDBACK)
+ }
+
+ @Test
+ fun testStartSurveySession_withOneQuestion_mandatoryQuestionOnly_succeeds() {
+ val mandatoryQuestionNameList = listOf(SurveyQuestionName.NPS)
+ surveyController.startSurveySession(
+ mandatoryQuestionNames = mandatoryQuestionNameList,
+ showOptionalQuestion = false
+ )
+
+ val result = surveyProgressController.getCurrentQuestion()
+ val ephemeralQuestion = monitorFactory.waitForNextSuccessfulResult(result)
+ assertThat(ephemeralQuestion.totalQuestionCount).isEqualTo(1)
+ assertThat(ephemeralQuestion.question.questionName)
+ .isEqualTo(SurveyQuestionName.NPS)
+ }
+
+ @Test
+ fun testStopSurveySession_withoutStartingSession_returnsFailure() {
+ val stopProvider = surveyController.stopSurveySession()
+
+ // The operation should be failing since the session hasn't started.
+ val result = monitorFactory.waitForNextFailureResult(stopProvider)
+
+ assertThat(result).isInstanceOf(IllegalStateException::class.java)
+ assertThat(result).hasMessageThat().contains("Session isn't initialized yet.")
+ }
+
+ private fun setUpTestApplicationComponent() {
+ ApplicationProvider.getApplicationContext()
+ .inject(this)
+ }
+
+ @Module
+ class TestModule {
+ internal companion object {
+ var enableLearnerStudyAnalytics = LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE
+ }
+
+ @Provides
+ @Singleton
+ fun provideContext(application: Application): Context {
+ return application
+ }
+
+ // TODO(#59): Either isolate these to their own shared test module, or use the real logging
+ @EnableConsoleLog
+ @Provides
+ fun provideEnableConsoleLog(): Boolean = true
+
+ @EnableFileLog
+ @Provides
+ fun provideEnableFileLog(): Boolean = false
+
+ @GlobalLogLevel
+ @Provides
+ fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE
+ }
+
+ @Module
+ class TestLoggingIdentifierModule {
+ companion object {
+ const val applicationIdSeed = 1L
+ }
+
+ @Provides
+ @ApplicationIdSeed
+ fun provideApplicationIdSeed(): Long = applicationIdSeed
+ }
+
+ // TODO(#89): Move this to a common test application component.
+ @Singleton
+ @Component(
+ modules = [
+ TestModule::class, RobolectricModule::class, FakeOppiaClockModule::class,
+ ApplicationLifecycleModule::class, TestDispatcherModule::class, LocaleProdModule::class,
+ ExplorationProgressModule::class, TestLogReportingModule::class, AssetModule::class,
+ NetworkConnectionUtilDebugModule::class, SyncStatusModule::class, LogStorageModule::class,
+ TestLoggingIdentifierModule::class,
+ ]
+ )
+ interface TestApplicationComponent : DataProvidersInjector {
+ @Component.Builder
+ interface Builder {
+ @BindsInstance
+ fun setApplication(application: Application): Builder
+
+ fun build(): TestApplicationComponent
+ }
+
+ fun inject(surveyControllerTest: SurveyControllerTest)
+ }
+
+ class TestApplication : Application(), DataProvidersInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerSurveyControllerTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build()
+ }
+
+ fun inject(surveyControllerTest: SurveyControllerTest) {
+ component.inject(surveyControllerTest)
+ }
+
+ override fun getDataProvidersInjector(): DataProvidersInjector = component
+ }
+}
diff --git a/domain/src/test/java/org/oppia/android/domain/survey/SurveyProgressControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/survey/SurveyProgressControllerTest.kt
new file mode 100644
index 00000000000..79f3cdcb1d4
--- /dev/null
+++ b/domain/src/test/java/org/oppia/android/domain/survey/SurveyProgressControllerTest.kt
@@ -0,0 +1,495 @@
+package org.oppia.android.domain.survey
+
+import android.app.Application
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import dagger.BindsInstance
+import dagger.Component
+import dagger.Module
+import dagger.Provides
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.app.model.EphemeralSurveyQuestion
+import org.oppia.android.app.model.MarketFitAnswer
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.model.UserTypeAnswer
+import org.oppia.android.domain.exploration.ExplorationProgressModule
+import org.oppia.android.domain.oppialogger.ApplicationIdSeed
+import org.oppia.android.domain.oppialogger.LogStorageModule
+import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
+import org.oppia.android.testing.FakeExceptionLogger
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.data.DataProviderTestMonitor
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.testing.time.FakeOppiaClockModule
+import org.oppia.android.util.caching.AssetModule
+import org.oppia.android.util.data.DataProvidersInjector
+import org.oppia.android.util.data.DataProvidersInjectorProvider
+import org.oppia.android.util.locale.LocaleProdModule
+import org.oppia.android.util.logging.EnableConsoleLog
+import org.oppia.android.util.logging.EnableFileLog
+import org.oppia.android.util.logging.GlobalLogLevel
+import org.oppia.android.util.logging.LogLevel
+import org.oppia.android.util.logging.SyncStatusModule
+import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.oppia.android.util.platformparameter.LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [SurveyProgressController]. */
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(application = SurveyProgressControllerTest.TestApplication::class)
+class SurveyProgressControllerTest {
+ @Inject
+ lateinit var fakeExceptionLogger: FakeExceptionLogger
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ @Inject
+ lateinit var monitorFactory: DataProviderTestMonitor.Factory
+
+ @Inject
+ lateinit var surveyController: SurveyController
+
+ @Inject
+ lateinit var surveyProgressController: SurveyProgressController
+
+ @Before
+ fun setUp() {
+ setUpTestApplicationComponent()
+ }
+
+ @Test
+ fun testStartSurveySession_succeeds() {
+ val surveyDataProvider =
+ surveyController.startSurveySession(questions)
+
+ monitorFactory.waitForNextSuccessfulResult(surveyDataProvider)
+ }
+
+ @Test
+ fun testStartSurveySession_sessionStartsWithInitialQuestion() {
+ startSuccessfulSurveySession()
+
+ val ephemeralQuestion = waitForGetCurrentQuestionSuccessfulLoad()
+ assertThat(ephemeralQuestion.question.questionName).isEqualTo(SurveyQuestionName.USER_TYPE)
+ }
+
+ @Test
+ fun testGetCurrentQuestion_sessionLoaded_returnsInitialQuestionPending() {
+ startSuccessfulSurveySession()
+
+ val ephemeralQuestion = waitForGetCurrentQuestionSuccessfulLoad()
+
+ assertThat(ephemeralQuestion.currentQuestionIndex).isEqualTo(0)
+ assertThat(ephemeralQuestion.totalQuestionCount).isGreaterThan(0)
+ assertThat(ephemeralQuestion.question.questionName).isEqualTo(SurveyQuestionName.USER_TYPE)
+ assertThat(ephemeralQuestion.hasPreviousQuestion).isEqualTo(false)
+ assertThat(ephemeralQuestion.hasNextQuestion).isEqualTo(true)
+ assertThat(ephemeralQuestion.questionTypeCase)
+ .isEqualTo(EphemeralSurveyQuestion.QuestionTypeCase.PENDING_QUESTION)
+ }
+
+ @Test
+ fun testGetCurrentQuestion_fourthQuestion_isTerminalQuestion() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+
+ val currentQuestion = submitNpsAnswer(7)
+
+ assertThat(currentQuestion.currentQuestionIndex).isEqualTo(3)
+ assertThat(currentQuestion.totalQuestionCount).isEqualTo(4)
+ assertThat(currentQuestion.questionTypeCase)
+ .isEqualTo(EphemeralSurveyQuestion.QuestionTypeCase.TERMINAL_QUESTION)
+ }
+
+ @Test
+ fun testGetCurrentQuestion_noSessionStarted_throwsException() {
+ // Can't retrieve the current question until the survey session is started.
+ val getCurrentQuestionProvider = surveyProgressController.getCurrentQuestion()
+
+ val result = monitorFactory.waitForNextFailureResult(getCurrentQuestionProvider)
+ assertThat(result).hasCauseThat().hasMessageThat().contains("Survey is not yet initialized.")
+ }
+
+ @Test
+ fun testSubmitAnswer_beforeStartingSurvey_isFailure() {
+ val submitAnswerProvider =
+ surveyProgressController.submitAnswer(createUserTypeAnswer(UserTypeAnswer.LEARNER))
+
+ // The operation should be failing since the session hasn't started.
+ val result = monitorFactory.waitForNextFailureResult(submitAnswerProvider)
+ assertThat(result).isInstanceOf(IllegalStateException::class.java)
+ assertThat(result).hasMessageThat().contains("Session isn't initialized yet.")
+ }
+
+ @Test
+ fun testSubmitAnswer_forUserTypeQuestion_succeeds() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+
+ val result = surveyProgressController.submitAnswer(createUserTypeAnswer(UserTypeAnswer.PARENT))
+
+ // Verify that the answer submission was successful.
+ monitorFactory.waitForNextSuccessfulResult(result)
+ }
+
+ @Test
+ fun testSubmitAnswer_forMarketFitQuestion_succeeds() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+
+ val result =
+ surveyProgressController.submitAnswer(
+ createMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+ )
+ monitorFactory.waitForNextSuccessfulResult(result)
+ }
+
+ @Test
+ fun testSubmitAnswer_forNpsScoreQuestion_succeeds() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+
+ val result =
+ surveyProgressController.submitAnswer(createNpsAnswer(9))
+ monitorFactory.waitForNextSuccessfulResult(result)
+ }
+
+ @Test
+ fun testSubmitAnswer_forTextInput_succeeds() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+ submitNpsAnswer(7)
+
+ val result =
+ surveyProgressController.submitAnswer(
+ createTextInputAnswer(
+ SurveyQuestionName.PASSIVE_FEEDBACK,
+ TEXT_ANSWER
+ )
+ )
+
+ monitorFactory.waitForNextSuccessfulResult(result)
+ }
+
+ @Test
+ fun testMoveToNext_beforePlaying_isFailure() {
+ val moveToNextProvider = surveyProgressController.moveToNextQuestion()
+
+ // The operation should be failing since the session hasn't started.
+ val result = monitorFactory.waitForNextFailureResult(moveToNextProvider)
+ assertThat(result).isInstanceOf(IllegalStateException::class.java)
+ assertThat(result).hasMessageThat().contains("Session isn't initialized yet.")
+ }
+
+ @Test
+ fun testMoveToNext_onTerminalQuestion_failsWithError() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+ submitNpsAnswer(7)
+ submitTextInputAnswer(SurveyQuestionName.PASSIVE_FEEDBACK, TEXT_ANSWER)
+
+ val moveToNextProvider = surveyProgressController.moveToNextQuestion()
+
+ val error = monitorFactory.waitForNextFailureResult(moveToNextProvider)
+
+ assertThat(error)
+ .hasMessageThat()
+ .contains("Cannot navigate to next question; at terminal question.")
+ }
+
+ @Test
+ fun testSubmitAnswer_submitNpsScore0f3_loadsDetractorFeedbackQuestion() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.NOT_DISAPPOINTED)
+
+ val ephemeralQuestion = submitNpsAnswer(3)
+
+ assertThat(ephemeralQuestion.currentQuestionIndex).isEqualTo(3)
+ assertThat(ephemeralQuestion.question.questionName)
+ .isEqualTo(SurveyQuestionName.DETRACTOR_FEEDBACK)
+ }
+
+ @Test
+ fun testSubmitAnswer_submitNpsScore0f7_loadsPassiveFeedbackQuestion() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.DISAPPOINTED)
+
+ val ephemeralQuestion = submitNpsAnswer(7)
+
+ assertThat(ephemeralQuestion.currentQuestionIndex).isEqualTo(3)
+ assertThat(ephemeralQuestion.question.questionName)
+ .isEqualTo(SurveyQuestionName.PASSIVE_FEEDBACK)
+ }
+
+ @Test
+ fun testSubmitAnswer_submitNpsScore0f10_loadsPromoterFeedbackQuestion() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+
+ val ephemeralQuestion = submitNpsAnswer(10)
+
+ assertThat(ephemeralQuestion.currentQuestionIndex).isEqualTo(3)
+ assertThat(ephemeralQuestion.question.questionName)
+ .isEqualTo(SurveyQuestionName.PROMOTER_FEEDBACK)
+ }
+
+ @Test
+ fun testMoveToPreviousQuestion_atInitialQuestion_isFailure() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+
+ val moveTolPreviousProvider = surveyProgressController.moveToPreviousQuestion()
+ val result = monitorFactory.waitForNextFailureResult(moveTolPreviousProvider)
+
+ assertThat(result).isInstanceOf(IllegalStateException::class.java)
+ assertThat(result).hasMessageThat()
+ .contains("Cannot navigate to previous question; at initial question.")
+ }
+
+ @Test
+ fun testMoveToPreviousQuestion_afterMovingToNextQuestion_isSuccess() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.LEARNER)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+
+ val currentQuestion = moveToPreviousQuestion()
+
+ assertThat(currentQuestion.currentQuestionIndex).isEqualTo(1)
+ assertThat(currentQuestion.question.questionName)
+ .isEqualTo(SurveyQuestionName.MARKET_FIT)
+ }
+
+ @Test
+ fun testSubmitAnswer_afterMovingToPreviousQuestion_isSuccess() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.LEARNER)
+ // Submit answer and move to next
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+
+ moveToPreviousQuestion()
+
+ // Submit a different answer to the navigated question
+ val submitAnswerProvider =
+ surveyProgressController.submitAnswer(createMarketFitAnswer(MarketFitAnswer.NOT_DISAPPOINTED))
+
+ // New answer is submitted successfully
+ monitorFactory.waitForNextSuccessfulResult(submitAnswerProvider)
+ }
+
+ @Test
+ fun testStopSurveySession_withoutStartingSession_returnsFailure() {
+ val stopProvider = surveyController.stopSurveySession()
+
+ // The operation should be failing since the session hasn't started.
+ val result = monitorFactory.waitForNextFailureResult(stopProvider)
+
+ assertThat(result).isInstanceOf(IllegalStateException::class.java)
+ assertThat(result).hasMessageThat().contains("Session isn't initialized yet.")
+ }
+
+ @Test
+ fun testStopSurveySession_afterStartingPreviousSession_succeeds() {
+ startSuccessfulSurveySession()
+ val stopProvider = surveyController.stopSurveySession()
+ monitorFactory.waitForNextSuccessfulResult(stopProvider)
+ }
+
+ private fun startSuccessfulSurveySession() {
+ monitorFactory.waitForNextSuccessfulResult(
+ surveyController.startSurveySession(questions)
+ )
+ }
+
+ private fun waitForGetCurrentQuestionSuccessfulLoad(): EphemeralSurveyQuestion {
+ return monitorFactory.waitForNextSuccessfulResult(
+ surveyProgressController.getCurrentQuestion()
+ )
+ }
+
+ private fun moveToPreviousQuestion(): EphemeralSurveyQuestion {
+ // This operation might fail for some tests.
+ monitorFactory.ensureDataProviderExecutes(
+ surveyProgressController.moveToPreviousQuestion()
+ )
+ return waitForGetCurrentQuestionSuccessfulLoad()
+ }
+
+ private fun submitAnswer(answer: SurveySelectedAnswer): EphemeralSurveyQuestion {
+ monitorFactory.waitForNextSuccessfulResult(
+ surveyProgressController.submitAnswer(answer)
+ )
+ return waitForGetCurrentQuestionSuccessfulLoad()
+ }
+
+ private fun submitUserTypeAnswer(answer: UserTypeAnswer): EphemeralSurveyQuestion {
+ return submitAnswer(createUserTypeAnswer(answer))
+ }
+
+ private fun createUserTypeAnswer(
+ answer: UserTypeAnswer
+ ): SurveySelectedAnswer {
+ return SurveySelectedAnswer.newBuilder()
+ .setQuestionName(SurveyQuestionName.USER_TYPE)
+ .setUserType(answer)
+ .build()
+ }
+
+ private fun submitMarketFitAnswer(answer: MarketFitAnswer): EphemeralSurveyQuestion {
+ return submitAnswer(createMarketFitAnswer(answer))
+ }
+
+ private fun createMarketFitAnswer(
+ answer: MarketFitAnswer
+ ): SurveySelectedAnswer {
+ return SurveySelectedAnswer.newBuilder()
+ .setQuestionName(SurveyQuestionName.MARKET_FIT)
+ .setMarketFit(answer)
+ .build()
+ }
+
+ private fun submitNpsAnswer(answer: Int): EphemeralSurveyQuestion {
+ return submitAnswer(createNpsAnswer(answer))
+ }
+
+ private fun createNpsAnswer(
+ answer: Int
+ ): SurveySelectedAnswer {
+ return SurveySelectedAnswer.newBuilder()
+ .setQuestionName(SurveyQuestionName.NPS)
+ .setNpsScore(answer)
+ .build()
+ }
+
+ private fun submitTextInputAnswer(
+ questionName: SurveyQuestionName,
+ textAnswer: String
+ ): EphemeralSurveyQuestion = submitAnswer(createTextInputAnswer(questionName, textAnswer))
+
+ private fun createTextInputAnswer(
+ questionName: SurveyQuestionName,
+ textAnswer: String
+ ): SurveySelectedAnswer {
+ return SurveySelectedAnswer.newBuilder()
+ .setQuestionName(questionName)
+ .setFreeFormAnswer(textAnswer)
+ .build()
+ }
+
+ private fun setUpTestApplicationComponent() {
+ ApplicationProvider.getApplicationContext()
+ .inject(this)
+ }
+
+ @Module
+ class TestModule {
+ internal companion object {
+ var enableLearnerStudyAnalytics = LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE
+ }
+
+ @Provides
+ @Singleton
+ fun provideContext(application: Application): Context {
+ return application
+ }
+
+ // TODO(#59): Either isolate these to their own shared test module, or use the real logging
+ @EnableConsoleLog
+ @Provides
+ fun provideEnableConsoleLog(): Boolean = true
+
+ @EnableFileLog
+ @Provides
+ fun provideEnableFileLog(): Boolean = false
+
+ @GlobalLogLevel
+ @Provides
+ fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE
+ }
+
+ @Module
+ class TestLoggingIdentifierModule {
+ companion object {
+ const val applicationIdSeed = 1L
+ }
+
+ @Provides
+ @ApplicationIdSeed
+ fun provideApplicationIdSeed(): Long = applicationIdSeed
+ }
+
+ // TODO(#89): Move this to a common test application component.
+ @Singleton
+ @Component(
+ modules = [
+ TestModule::class, RobolectricModule::class, FakeOppiaClockModule::class,
+ ApplicationLifecycleModule::class, TestDispatcherModule::class, LocaleProdModule::class,
+ ExplorationProgressModule::class, TestLogReportingModule::class, AssetModule::class,
+ NetworkConnectionUtilDebugModule::class, SyncStatusModule::class, LogStorageModule::class,
+ TestLoggingIdentifierModule::class
+ ]
+ )
+
+ interface TestApplicationComponent : DataProvidersInjector {
+ @Component.Builder
+ interface Builder {
+ @BindsInstance
+ fun setApplication(application: Application): Builder
+
+ fun build(): TestApplicationComponent
+ }
+
+ fun inject(surveyProgressControllerTest: SurveyProgressControllerTest)
+ }
+
+ class TestApplication : Application(), DataProvidersInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerSurveyProgressControllerTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build()
+ }
+
+ fun inject(surveyProgressControllerTest: SurveyProgressControllerTest) {
+ component.inject(surveyProgressControllerTest)
+ }
+
+ override fun getDataProvidersInjector(): DataProvidersInjector = component
+ }
+
+ companion object {
+ private const val TEXT_ANSWER = "Some text answer"
+ private val questions = listOf(
+ SurveyQuestionName.USER_TYPE,
+ SurveyQuestionName.MARKET_FIT,
+ SurveyQuestionName.NPS
+ )
+ }
+}
diff --git a/model/src/main/proto/BUILD.bazel b/model/src/main/proto/BUILD.bazel
index cc7e9fa399c..dcc48bc0202 100644
--- a/model/src/main/proto/BUILD.bazel
+++ b/model/src/main/proto/BUILD.bazel
@@ -337,6 +337,7 @@ oppia_proto_library(
name = "survey_proto",
srcs = ["survey.proto"],
visibility = ["//:oppia_api_visibility"],
+ deps = [":languages_proto"],
)
java_lite_proto_library(
diff --git a/model/src/main/proto/arguments.proto b/model/src/main/proto/arguments.proto
index c8e7119366f..4d9668acaa8 100644
--- a/model/src/main/proto/arguments.proto
+++ b/model/src/main/proto/arguments.proto
@@ -303,3 +303,11 @@ message AppLanguageActivityStateBundle {
// The default app language selected by the user.
OppiaLanguage oppia_language = 1;
}
+
+message SurveyActivityParams {
+ // The ID of the profile for which the survey is to be shown.
+ ProfileId profile_id = 1;
+
+ // The ID of the topic to which the triggering exploration belongs.
+ string topic_id = 2;
+}
diff --git a/model/src/main/proto/screens.proto b/model/src/main/proto/screens.proto
index dae4c8b99be..e0ee3599d6d 100644
--- a/model/src/main/proto/screens.proto
+++ b/model/src/main/proto/screens.proto
@@ -155,6 +155,9 @@ enum ScreenName {
// A generic foreground screen value for logging periodic metrics like CPU usage.
FOREGROUND_SCREEN = 48;
+
+ // Screen name value for the scenario when the survey activity is visible to the user.
+ SURVEY_ACTIVITY = 49;
}
// Defines the current visible UI screen of the application.
diff --git a/model/src/main/proto/survey.proto b/model/src/main/proto/survey.proto
index 86d1c52a4da..c32481a02af 100644
--- a/model/src/main/proto/survey.proto
+++ b/model/src/main/proto/survey.proto
@@ -14,8 +14,11 @@ message Survey {
// This field is used to retrieve information about a survey, and should be treated as read-only.
string survey_id = 1;
- // A list of questions that make up the survey.
- repeated SurveyQuestion questions = 2;
+ // A list of questions which if not answered in full, the survey is considered incomplete.
+ repeated SurveyQuestion mandatory_questions = 2;
+
+ // An optional question that allows the user to provide additional feedback during the survey.
+ SurveyQuestion optional_question = 3;
}
// Represents a question that is part of the Survey.
@@ -56,7 +59,7 @@ enum SurveyQuestionName {
// Corresponds to the name of the user type question.
USER_TYPE = 1;
- // Corresponds to the market fit question.
+ // Corresponds to the market fit question.
MARKET_FIT = 2;
// Corresponds to the NPS question.
@@ -103,16 +106,23 @@ message SurveySelectedAnswer {
// Semantic name of the question the response is tied to.
SurveyQuestionName question_name = 2;
+ // The ID of the survey question. This is a string value that uniquely identifies the question
+ // within the context of the survey, and is guaranteed to be unique within the survey scope only.
+ string question_id = 3;
+
// The value of the answer the user gave.
oneof answer {
// The enum representation of the type of user answer selected.
- UserTypeAnswer user_type = 3;
+ UserTypeAnswer user_type = 4;
// The enum representation of the market fit answer selected.
- MarketFitAnswer market_fit = 4;
+ MarketFitAnswer market_fit = 5;
// The integer value representing the nps score selected by the user.
- int32 nps_score = 5;
+ int32 nps_score = 6;
+
+ // The string value representing the free text answer entered by a user.
+ string free_form_answer = 7;
}
}
@@ -122,8 +132,8 @@ enum UserTypeAnswer {
// User type unknown.
USER_TYPE_UNSPECIFIED = 0;
- // Corresponds to an unspecified or unknown type of user.
- OTHER = 1;
+ // Corresponds to a user who is a learner.
+ LEARNER = 1;
// Corresponds to a user who is a teacher.
TEACHER = 2;
@@ -131,8 +141,8 @@ enum UserTypeAnswer {
// Corresponds to a user who is a parent.
PARENT = 3;
- // Corresponds to a user who is a learner.
- LEARNER = 4;
+ // Corresponds to an unspecified or unknown type of user.
+ OTHER = 4;
}
// Represents general opinion of the user about the Oppia android app.
@@ -191,10 +201,26 @@ message EphemeralSurveyQuestion {
// A pending question that requires an answer to continue.
bool pending_question = 4;
- // The SurveySelectedAnswer given to complete the previous question.
+ // The SurveySelectedAnswer given to complete this question.
SurveySelectedAnswer selected_answer = 5;
// Present if this is the last question in the survey.
bool terminal_question = 6;
}
+
+ // Corresponds the index of the current question in the survey, starting at 0. This index is
+ // guaranteed to be unique for a specific question and will be equal to the total question count if
+ // the learner has reached the end of the survey.
+ int32 current_question_index = 7;
+
+ // Corresponds to the number of questions in the survey. This value will never change in the same
+ // survey instance.
+ int32 total_question_count = 8;
+}
+
+// Top-level proto used to store the user selected answers in a survey, and is expected to only
+// exist ephemerally in memory during the survey session.
+message SelectedAnswerDatabase {
+ // Map from question ID to SurveySelectedAnswer for that question.
+ map selected_answer = 1;
}
diff --git a/scripts/assets/kdoc_validity_exemptions.textproto b/scripts/assets/kdoc_validity_exemptions.textproto
index e986f8ed91c..94f9f14c159 100644
--- a/scripts/assets/kdoc_validity_exemptions.textproto
+++ b/scripts/assets/kdoc_validity_exemptions.textproto
@@ -3,6 +3,7 @@ exempted_file_path: "app/src/main/java/org/oppia/android/app/application/Applica
exempted_file_path: "app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/application/ApplicationStartupListenerModule.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/administratorcontrols/RouteToProfileListListener.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/SurveyOnboardingBackgroundView.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/notice/AutomaticAppDeprecationNoticeDialogFragmentPresenter.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivityPresenter.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsFragment.kt"
@@ -188,6 +189,19 @@ exempted_file_path: "app/src/main/java/org/oppia/android/app/story/StoryFragment
exempted_file_path: "app/src/main/java/org/oppia/android/app/story/StoryFragmentScroller.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/story/StoryViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/AudioFragmentTestActivity.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/AudioFragmentTestActivityPresenter.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/BindableAdapterTestActivity.kt"
@@ -290,6 +304,7 @@ exempted_file_path: "domain/src/main/java/org/oppia/android/domain/oppialogger/l
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/question/QuestionAssessmentProgress.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/question/QuestionConstantsProvider.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/question/QuestionTrainingConstantsProvider.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/survey/SurveyProgress.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/TopicController.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt"
diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto
index e4cfb2dade9..3babe278f56 100644
--- a/scripts/assets/test_file_exemptions.textproto
+++ b/scripts/assets/test_file_exemptions.textproto
@@ -71,6 +71,7 @@ exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/ChapterN
exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/ContinueButtonView.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/PromotedStoryCardView.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/SegmentedCircularProgressView.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/SurveyOnboardingBackgroundView.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/VerticalDashedLineView.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/interaction/FractionInputInteractionView.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/interaction/NumericInputInteractionView.kt"
@@ -434,6 +435,24 @@ exempted_file_path: "app/src/main/java/org/oppia/android/app/story/StoryViewMode
exempted_file_path: "app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryHeaderViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryItemViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/AppCompatCheckBoxBindingAdaptersTestActivity.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/AudioFragmentTestActivity.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/AudioFragmentTestActivityPresenter.kt"
@@ -694,6 +713,10 @@ exempted_file_path: "domain/src/main/java/org/oppia/android/domain/question/Ques
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/state/StateGraph.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/state/StateList.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/survey/SurveyProgress.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/survey/SurveyConstantsProvider.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionGraph.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/testing/oppialogger/loguploader/FakeLogUploader.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/ConceptCardRetriever.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/PrimeTopicAssetsController.kt"
diff --git a/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterModule.kt b/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterModule.kt
index 72c189250db..7ff02772a65 100644
--- a/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterModule.kt
+++ b/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterModule.kt
@@ -37,7 +37,7 @@ import org.oppia.android.util.platformparameter.LOWEST_SUPPORTED_API_LEVEL
import org.oppia.android.util.platformparameter.LOWEST_SUPPORTED_API_LEVEL_DEFAULT_VALUE
import org.oppia.android.util.platformparameter.LowestSupportedApiLevel
import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS_DEFAULT_VALUE
-import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VAL
+import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
import org.oppia.android.util.platformparameter.NpsSurveyGracePeriodInDays
import org.oppia.android.util.platformparameter.NpsSurveyMinimumAggregateLearningTimeInATopicInMinutes
import org.oppia.android.util.platformparameter.OPTIONAL_APP_UPDATE_VERSION_CODE
@@ -296,7 +296,7 @@ class TestPlatformParameterModule {
private var enableSpotlightUi = true
private var enableAppAndOsDeprecation = ENABLE_APP_AND_OS_DEPRECATION_DEFAULT_VALUE
private var minimumLearningTime =
- NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VAL
+ NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
private var gracePeriodInDays = NPS_SURVEY_GRACE_PERIOD_IN_DAYS_DEFAULT_VALUE
/** Enables forcing [EnableLanguageSelectionUi] platform parameter flag from tests. */
diff --git a/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt b/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt
index e297801c763..cc6582638e1 100644
--- a/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt
+++ b/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt
@@ -660,6 +660,7 @@ class EventBundleCreator @Inject constructor(
ScreenName.POLICIES_ACTIVITY -> "policies_activity"
ScreenName.UNRECOGNIZED -> "unrecognized"
ScreenName.FOREGROUND_SCREEN -> "foreground_screen"
+ ScreenName.SURVEY_ACTIVITY -> "survey_activity"
}
private fun AppLanguageSelection.toAnalyticsText(): String {
diff --git a/utility/src/main/java/org/oppia/android/util/platformparameter/PlatformParameterConstants.kt b/utility/src/main/java/org/oppia/android/util/platformparameter/PlatformParameterConstants.kt
index 18052dc6e1d..8cb1e1b4297 100644
--- a/utility/src/main/java/org/oppia/android/util/platformparameter/PlatformParameterConstants.kt
+++ b/utility/src/main/java/org/oppia/android/util/platformparameter/PlatformParameterConstants.kt
@@ -358,4 +358,4 @@ const val NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES =
* had sufficient interaction with the app to be able to provide informed feedback about their
* experience with the app.
*/
-const val NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VAL = 5
+const val NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE = 5
From 41671440eb68b370f64b451c37cca13dcae0e1b5 Mon Sep 17 00:00:00 2001
From: aayushimathur6 <92685651+aayushimathur6@users.noreply.github.com>
Date: Fri, 7 Jul 2023 19:36:38 +0530
Subject: [PATCH 05/33] FIX #4852: "Profile Deletion" button is misaligned in
landscape on a phone (#4976)
## Explanation
-->Fixes #4852
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
- | Before | After |
| ----------------- | --------------------- |
|
![before](https://github.com/oppia/oppia-android/assets/92685651/35d26710-f49b-49e2-b7c6-529e1276599e)
|
![after](https://github.com/oppia/oppia-android/assets/92685651/39ff1955-1c7a-4e4c-a21a-b8e6792e2faa)
|
Videos for more explanation on two different emulators (landscape
mode):-
1) Running on 6+ device
[sixplus.webm](https://github.com/oppia/oppia-android/assets/92685651/781763ad-772e-43b5-9560-61bbe2d3b30e)
2) Running on 4 WVGA (Nexus S)
[WVGA (Nexus
S).webm](https://github.com/oppia/oppia-android/assets/92685651/18800cf0-b5e5-4eb0-8ead-bd0596cdc2c2)
---------
Co-authored-by: Adhiambo Peres <59600948+adhiamboperes@users.noreply.github.com>
---
app/src/main/res/layout-land/profile_edit_fragment.xml | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/app/src/main/res/layout-land/profile_edit_fragment.xml b/app/src/main/res/layout-land/profile_edit_fragment.xml
index 09ea627140b..352b3c2ce86 100644
--- a/app/src/main/res/layout-land/profile_edit_fragment.xml
+++ b/app/src/main/res/layout-land/profile_edit_fragment.xml
@@ -108,6 +108,7 @@
android:textColor="@color/component_color_shared_primary_text_color"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/profile_rename_button" />
@@ -128,6 +129,7 @@
android:textSize="16sp"
android:visibility="@{viewModel.isAllowedToMarkFinishedChapters ? View.VISIBLE : View.GONE}"
app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/profile_reset_button" />
@@ -216,10 +218,10 @@
android:text="@string/profile_edit_allow_download_heading"
android:textColor="@color/component_color_shared_primary_text_color"
android:textSize="16sp"
+ app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/profile_edit_allow_download_switch"
app:layout_constraintHorizontal_bias="0.0"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
+ app:layout_constraintStart_toStartOf="parent" />
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/profile_edit_enable_in_lesson_language_switching_container" />
From f7cf96a7b515e1639e97be0ac9311bb0fbc4ccca Mon Sep 17 00:00:00 2001
From: XichengSpencer <74568012+XichengSpencer@users.noreply.github.com>
Date: Sat, 8 Jul 2023 19:53:08 -0400
Subject: [PATCH 06/33] Fix #5059 build error for truth package (#5078)
## Explanation
Fix #5059 This pull request contains a gradle change, import the missing
test package to androidTestImplementation to resolve the build error.
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
Co-authored-by: Adhiambo Peres <59600948+adhiamboperes@users.noreply.github.com>
---
app/build.gradle | 1 +
1 file changed, 1 insertion(+)
diff --git a/app/build.gradle b/app/build.gradle
index 34aeccd28b1..71b759c6475 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -210,6 +210,7 @@ dependencies {
'androidx.test.espresso:espresso-core:3.2.0',
'androidx.test.espresso:espresso-intents:3.1.0',
'androidx.test.ext:junit:1.1.1',
+ 'androidx.test.ext:truth:1.4.0',
'com.github.bumptech.glide:mocks:4.11.0',
'com.google.truth:truth:1.1.3',
'androidx.work:work-testing:2.4.0',
From dbbbccff14349c5c368ca5ded41f9ffbc7893dd0 Mon Sep 17 00:00:00 2001
From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com>
Date: Mon, 10 Jul 2023 21:44:26 -0600
Subject: [PATCH 07/33] Fix part of #5079 : Updated wiki for Gradle Build
Failed - Task :utility:kaptGenerateStubsDebugKotlin FAILED (#5083)
## Explanation
Fix part of #5079 : Updated wiki to add information about resolving
common setup error: Gradle Build Failed - Task
:utility:kaptGenerateStubsDebugKotlin FAILED
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---
wiki/Troubleshooting-Installation.md | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/wiki/Troubleshooting-Installation.md b/wiki/Troubleshooting-Installation.md
index ecf2a27e7bf..55d14907a1d 100644
--- a/wiki/Troubleshooting-Installation.md
+++ b/wiki/Troubleshooting-Installation.md
@@ -39,6 +39,23 @@ Here are some general troubleshooting tips for oppia-android. The specific platf
or `Module not specified` while running Unit Tests, try to downgrade Android Studio to [Bumblebee (Patch 3)](https://developer.android.com/studio/archive). That should resolve this issue.
+7. If you encounter this error while building gradle:
+
+ ```
+ > Task :utility:kaptGenerateStubsDebugKotlin FAILED
+ Execution failed for task ':utility:kaptGenerateStubsDebugKotlin'.
+ > Could not resolve all files for configuration ':utility:debugCompileClasspath'.
+ > Failed to transform model.jar (project :model) to match attributes {artifactType=android-classes, org.gradle.category=library, org.gradle.dependency.bundling=external, org.gradle.jvm.version=15, org.gradle.libraryelements=jar, org.gradle.usage=java-api}.
+ > Execution failed for JetifyTransform: E:\Android\open-source\oppia-android\model\build\libs\model.jar.
+ > Failed to transform 'E:\Android\open-source\oppia-android\model\build\libs\model.jar' using Jetifier. Reason: Unsupported class file major version 59. (Run with --stacktrace for more details.)
+ ```
+ You are seeing this because Oppia android currently compiles with Java 8, or 9. Higher versions of Java are not supported by our version of Gradle.
+
+ The `model.jar` was compiled with Java 15/major version 59, hence the incompatibility.
+
+
+ To fix this error, you need to lower the version of Java to compile the JAR file. Please see [here](https://developer.android.com/studio/intro/studio-config#jdk) for more information about Java versions.
+
### Bazel issues
1. No matching toolchains (sdk_toolchain_type)
From 1862c4a16e2b4ddc6e5352dc73c589f148cfba9f Mon Sep 17 00:00:00 2001
From: masclot <103062089+masclot@users.noreply.github.com>
Date: Tue, 11 Jul 2023 16:53:50 +0200
Subject: [PATCH 08/33] Fix #632: Move PromotedStoryListAdapter to
BindableAdater (#5077)
Fix #632: Move PromotedStoryListAdapter to BindableAdater
This change reverts partially #4951, which in turn reverted #4874 due to
a bug.
It is a partial revert because the bug was fixed independently in the
previous PR #4965.
I added RecentlyPlayedViewModel.kt to the list of classes not needing
tests, as is usual for ViewModels. This was also part of the original
PR.
| current | new |
|- | - |
|![recently_played_ltr_current](https://github.com/oppia/oppia-android/assets/103062089/179e56c1-6548-4be0-ac03-a0b7252c3a4a)|
![recently_played_ltr_new](https://github.com/oppia/oppia-android/assets/103062089/468d13e2-f6c8-48f0-a36c-2483f6dbe728)|
|![recently_played_rtl_current](https://github.com/oppia/oppia-android/assets/103062089/cf78dd4b-4fef-4311-accb-bd03d28345d6)|
![recently_played_rtl_new](https://github.com/oppia/oppia-android/assets/103062089/90a489b4-7e2a-49b3-9cbc-1eebd6dfe491)
|
|![recently_played_ltr_landscape_current](https://github.com/oppia/oppia-android/assets/103062089/92b12fa4-54c4-4be5-8c4f-1c73e90e9cb1)|![recently_played_ltr_landscape_new](https://github.com/oppia/oppia-android/assets/103062089/39833703-9cbd-4b63-b882-5950d3154316)|
|![recently_played_rtl_landscape_current](https://github.com/oppia/oppia-android/assets/103062089/46545cf6-40aa-4f24-a977-a0a586b46445)|![recently_played_rtl_landscape_new](https://github.com/oppia/oppia-android/assets/103062089/89c6a842-e876-4dc3-8861-c2c47a938183)|
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility guide](https://github.com/op
pia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---------
Co-authored-by: Adhiambo Peres <59600948+adhiamboperes@users.noreply.github.com>
---
app/BUILD.bazel | 1 +
.../PromotedStoryListAdapter.kt | 110 ---------
.../RecentlyPlayedFragmentPresenter.kt | 225 ++++--------------
.../recentlyplayed/RecentlyPlayedViewModel.kt | 172 +++++++++++++
.../res/layout/recently_played_fragment.xml | 10 +-
scripts/assets/test_file_exemptions.textproto | 1 +
6 files changed, 235 insertions(+), 284 deletions(-)
delete mode 100644 app/src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryListAdapter.kt
create mode 100644 app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt
diff --git a/app/BUILD.bazel b/app/BUILD.bazel
index d5a4bfe97fe..5da39359c88 100644
--- a/app/BUILD.bazel
+++ b/app/BUILD.bazel
@@ -210,6 +210,7 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [
"src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryListViewModel.kt",
"src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryViewModel.kt",
"src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryViewModel.kt",
+ "src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt",
"src/main/java/org/oppia/android/app/home/topiclist/TopicSummaryViewModel.kt",
"src/main/java/org/oppia/android/app/onboarding/OnboadingSlideViewModel.kt",
"src/main/java/org/oppia/android/app/onboarding/OnboardingViewModel.kt",
diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryListAdapter.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryListAdapter.kt
deleted file mode 100644
index 61bcf6bf5e6..00000000000
--- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryListAdapter.kt
+++ /dev/null
@@ -1,110 +0,0 @@
-package org.oppia.android.app.home.recentlyplayed
-
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import org.oppia.android.databinding.RecentlyPlayedStoryCardBinding
-import org.oppia.android.databinding.SectionTitleBinding
-
-private const val VIEW_TYPE_SECTION_TITLE_TEXT = 1
-private const val VIEW_TYPE_SECTION_STORY_ITEM = 2
-
-/**
- * Adapter to inflate different items/views inside [RecyclerView] for Ongoing Story List.
- *
- * @property itemList the items that may be displayed in [RecentlyPlayedFragment]'s recycler view
- */
-class PromotedStoryListAdapter(
- private val itemList: MutableList
-) : RecyclerView.Adapter() {
-
- private var titleIndex: Int = 0
- private var storyGridPosition: Int = 0
- private var spanCount = 0
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
- return when (viewType) {
- // TODO(#632): Generalize this binding to make adding future items easier.
- VIEW_TYPE_SECTION_TITLE_TEXT -> {
- val inflater = LayoutInflater.from(parent.context)
- val binding =
- SectionTitleBinding.inflate(
- inflater,
- parent,
- /* attachToParent= */ false
- )
- SectionTitleViewHolder(binding)
- }
- VIEW_TYPE_SECTION_STORY_ITEM -> {
- val inflater = LayoutInflater.from(parent.context)
- val binding =
- RecentlyPlayedStoryCardBinding.inflate(
- inflater,
- parent,
- /* attachToParent= */ false
- )
- PromotedStoryViewHolder(binding)
- }
- else -> throw IllegalArgumentException("Invalid view type: $viewType")
- }
- }
-
- override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) =
- when (holder.itemViewType) {
- VIEW_TYPE_SECTION_TITLE_TEXT -> {
- titleIndex = position
- (holder as SectionTitleViewHolder).bind(itemList[position] as SectionTitleViewModel)
- }
- VIEW_TYPE_SECTION_STORY_ITEM -> {
- storyGridPosition = position - titleIndex
- (holder as PromotedStoryViewHolder).bind(itemList[position] as PromotedStoryViewModel)
- }
- else -> throw IllegalArgumentException("Invalid item view type: ${holder.itemViewType}")
- }
-
- override fun getItemViewType(position: Int): Int {
- return when (itemList[position]) {
- is SectionTitleViewModel -> {
- VIEW_TYPE_SECTION_TITLE_TEXT
- }
- is PromotedStoryViewModel -> {
- VIEW_TYPE_SECTION_STORY_ITEM
- }
- else -> throw IllegalArgumentException(
- "Invalid type of data $position with item ${itemList[position]}"
- )
- }
- }
-
- override fun getItemCount(): Int {
- return itemList.size
- }
-
-/**
- * Specifies the number of columns of recently played stories shown in the recently played stories
- * list.
- *
- * @param spanCount specifies the number of spaces this item should occupy, based on screen size
- */
- fun setSpanCount(spanCount: Int) {
- this.spanCount = spanCount
- }
-
- private class SectionTitleViewHolder(
- val binding: SectionTitleBinding
- ) : RecyclerView.ViewHolder(binding.root) {
- /** Binds the view model that sets section titles. */
- fun bind(sectionTitleViewModel: SectionTitleViewModel) {
- binding.viewModel = sectionTitleViewModel
- }
- }
-
- private class PromotedStoryViewHolder(
- val binding: RecentlyPlayedStoryCardBinding
- ) : RecyclerView.ViewHolder(binding.root) {
- /** Binds the view model that sets recently played items. */
- fun bind(promotedStoryViewModel: PromotedStoryViewModel) {
- binding.viewModel = promotedStoryViewModel
- }
- }
-}
diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt
index 11ace445d1e..fcd8a8b3c7c 100755
--- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt
@@ -5,9 +5,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
-import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
-import androidx.lifecycle.Transformations
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.oppia.android.R
@@ -17,19 +15,17 @@ import org.oppia.android.app.model.ChapterPlayState
import org.oppia.android.app.model.ExplorationActivityParams
import org.oppia.android.app.model.ExplorationCheckpoint
import org.oppia.android.app.model.ProfileId
-import org.oppia.android.app.model.PromotedActivityList
import org.oppia.android.app.model.PromotedStory
+import org.oppia.android.app.recyclerview.BindableAdapter
import org.oppia.android.app.topic.RouteToResumeLessonListener
-import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.databinding.RecentlyPlayedFragmentBinding
+import org.oppia.android.databinding.RecentlyPlayedStoryCardBinding
+import org.oppia.android.databinding.SectionTitleBinding
import org.oppia.android.domain.exploration.ExplorationDataController
import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationCheckpointController
import org.oppia.android.domain.oppialogger.OppiaLogger
-import org.oppia.android.domain.topic.TopicListController
-import org.oppia.android.domain.translation.TranslationController
import org.oppia.android.util.data.AsyncResult
import org.oppia.android.util.data.DataProviders.Companion.toLiveData
-import org.oppia.android.util.parser.html.StoryHtmlParserEntityType
import javax.inject.Inject
/** The presenter for [RecentlyPlayedFragment]. */
@@ -39,189 +35,50 @@ class RecentlyPlayedFragmentPresenter @Inject constructor(
private val fragment: Fragment,
private val oppiaLogger: OppiaLogger,
private val explorationDataController: ExplorationDataController,
- private val topicListController: TopicListController,
private val explorationCheckpointController: ExplorationCheckpointController,
- @StoryHtmlParserEntityType private val entityType: String,
- private val resourceHandler: AppLanguageResourceHandler,
- private val translationController: TranslationController
+ private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory,
+ private val recentlyPlayedViewModelFactory: RecentlyPlayedViewModel.Factory
) {
private val routeToResumeLessonListener = activity as RouteToResumeLessonListener
private val routeToExplorationListener = activity as RouteToExplorationListener
- private var internalProfileId: Int = -1
+
+ private lateinit var profileId: ProfileId
private lateinit var binding: RecentlyPlayedFragmentBinding
- private lateinit var promotedStoryListAdapter: PromotedStoryListAdapter
- private val itemList: MutableList = ArrayList()
fun handleCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
internalProfileId: Int
): View? {
- binding = RecentlyPlayedFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
-
- this.internalProfileId = internalProfileId
-
- promotedStoryListAdapter = PromotedStoryListAdapter(itemList)
- binding.ongoingStoryRecyclerView.apply {
- adapter = promotedStoryListAdapter
- }
- binding.lifecycleOwner = fragment
-
- subscribeToPromotedStoryList()
- return binding.root
- }
-
- private val promotedStoryListSummaryResultLiveData:
- LiveData>
- by lazy {
- topicListController.getPromotedActivityList(
- ProfileId.newBuilder().setInternalId(internalProfileId).build()
- ).toLiveData()
- }
-
- private fun subscribeToPromotedStoryList() {
- getAssumedSuccessfulPromotedActivityList().observe(
- fragment,
- {
- if (it.promotedStoryList.recentlyPlayedStoryList.isNotEmpty()) {
- addRecentlyPlayedStoryListSection(it.promotedStoryList.recentlyPlayedStoryList)
- }
-
- if (it.promotedStoryList.olderPlayedStoryList.isNotEmpty()) {
- addOlderStoryListSection(it.promotedStoryList.olderPlayedStoryList)
- }
-
- if (it.promotedStoryList.suggestedStoryList.isNotEmpty()) {
- addRecommendedStoryListSection(it.promotedStoryList.suggestedStoryList)
- }
-
- binding.ongoingStoryRecyclerView.layoutManager =
- createLayoutManager(
- it.promotedStoryList.recentlyPlayedStoryCount,
- it.promotedStoryList.olderPlayedStoryCount,
- it.promotedStoryList.suggestedStoryCount
- )
- promotedStoryListAdapter.notifyDataSetChanged()
- }
- )
- }
-
- private fun addRecentlyPlayedStoryListSection(
- recentlyPlayedStoryList: MutableList
- ) {
- itemList.clear()
- val recentSectionTitleViewModel =
- SectionTitleViewModel(
- resourceHandler.getStringInLocale(R.string.ongoing_story_last_week), false
- )
- itemList.add(recentSectionTitleViewModel)
- recentlyPlayedStoryList.forEachIndexed { index, promotedStory ->
- val ongoingStoryViewModel = createOngoingStoryViewModel(promotedStory, index)
- itemList.add(ongoingStoryViewModel)
- }
- }
-
- private fun createOngoingStoryViewModel(
- promotedStory: PromotedStory,
- index: Int
- ): RecentlyPlayedItemViewModel {
- return PromotedStoryViewModel(
- activity,
- promotedStory,
- entityType,
+ this.profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build()
+ val recentlyPlayedViewModel = recentlyPlayedViewModelFactory.create(
fragment as PromotedStoryClickListener,
- index,
- resourceHandler,
- translationController
+ this.profileId
)
- }
-
- private fun addOlderStoryListSection(olderPlayedStoryList: List) {
- val showDivider = itemList.isNotEmpty()
- val olderSectionTitleViewModel =
- SectionTitleViewModel(
- resourceHandler.getStringInLocale(R.string.ongoing_story_last_month),
- showDivider
- )
- itemList.add(olderSectionTitleViewModel)
- olderPlayedStoryList.forEachIndexed { index, promotedStory ->
- val ongoingStoryViewModel = createOngoingStoryViewModel(promotedStory, index)
- itemList.add(ongoingStoryViewModel)
- }
- }
-
- private fun addRecommendedStoryListSection(suggestedStoryList: List) {
- val showDivider = itemList.isNotEmpty()
- val recommendedSectionTitleViewModel =
- SectionTitleViewModel(
- resourceHandler.getStringInLocale(R.string.recommended_stories),
- showDivider
- )
- itemList.add(recommendedSectionTitleViewModel)
- suggestedStoryList.forEachIndexed { index, suggestedStory ->
- val ongoingStoryViewModel = createOngoingStoryViewModel(suggestedStory, index)
- itemList.add(ongoingStoryViewModel)
- }
- }
-
- private fun getAssumedSuccessfulPromotedActivityList(): LiveData {
- return Transformations.map(promotedStoryListSummaryResultLiveData) {
- when (it) {
- // If there's an error loading the data, assume the default.
- is AsyncResult.Failure, is AsyncResult.Pending -> PromotedActivityList.getDefaultInstance()
- is AsyncResult.Success -> it.value
+ binding =
+ RecentlyPlayedFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false).apply {
+ lifecycleOwner = fragment
+ viewModel = recentlyPlayedViewModel
+ val adapter = createRecyclerViewAdapter()
+ ongoingStoryRecyclerView.layoutManager = createLayoutManager(adapter)
+ ongoingStoryRecyclerView.adapter = adapter
}
- }
+
+ return binding.root
}
private fun createLayoutManager(
- recentStoryCount: Int,
- oldStoryCount: Int,
- suggestedStoryCount: Int
+ adapter: BindableAdapter
): RecyclerView.LayoutManager {
- val sectionTitle0Position = if (recentStoryCount == 0) {
- // If recent story count is 0, that means that section title 0 will not be visible.
- -1
- } else {
- 0
- }
- val sectionTitle1Position = if (oldStoryCount == 0) {
- // If old story count is 0, that means that section title 1 will not be visible.
- -1
- } else if (recentStoryCount == 0) {
- 0
- } else {
- recentStoryCount + 1
- }
- val sectionTitle2Position = when {
- suggestedStoryCount == 0 -> {
- -1 // If suggested story count is 0, that means that section title 1 will not be visible.
- }
- oldStoryCount == 0 && recentStoryCount == 0 -> {
- 0
- }
- oldStoryCount > 0 && recentStoryCount > 0 -> {
- recentStoryCount + oldStoryCount + 2
- }
- else -> {
- recentStoryCount + oldStoryCount + 1
- }
- }
-
val spanCount = activity.resources.getInteger(R.integer.recently_played_span_count)
- promotedStoryListAdapter.setSpanCount(spanCount)
-
val layoutManager = GridLayoutManager(activity.applicationContext, spanCount)
layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
- return when (position) {
- sectionTitle0Position, sectionTitle1Position, sectionTitle2Position -> {
- /* number of spaces this item should occupy = */ spanCount
- }
- else -> {
- /* number of spaces this item should occupy = */ 1
- }
+ return if (adapter.getItemViewType(position) == ViewType.VIEW_TYPE_TITLE.ordinal) {
+ spanCount
+ } else {
+ 1
}
}
}
@@ -229,9 +86,6 @@ class RecentlyPlayedFragmentPresenter @Inject constructor(
}
fun promotedStoryClicked(promotedStory: PromotedStory) {
- val profileId = ProfileId.newBuilder().apply {
- internalId = internalProfileId
- }.build()
val canHavePartialProgressSaved =
when (promotedStory.chapterPlayState) {
ChapterPlayState.IN_PROGRESS_SAVED, ChapterPlayState.IN_PROGRESS_NOT_SAVED,
@@ -282,6 +136,31 @@ class RecentlyPlayedFragmentPresenter @Inject constructor(
}
}
+ private enum class ViewType {
+ VIEW_TYPE_TITLE,
+ VIEW_TYPE_PROMOTED_STORY
+ }
+
+ private fun createRecyclerViewAdapter(): BindableAdapter {
+ return multiTypeBuilderFactory.create { viewModel ->
+ when (viewModel) {
+ is PromotedStoryViewModel -> ViewType.VIEW_TYPE_PROMOTED_STORY
+ is SectionTitleViewModel -> ViewType.VIEW_TYPE_TITLE
+ else -> throw IllegalArgumentException("Encountered unexpected view model: $viewModel")
+ }
+ }.registerViewDataBinder(
+ viewType = ViewType.VIEW_TYPE_TITLE,
+ inflateDataBinding = SectionTitleBinding::inflate,
+ setViewModel = SectionTitleBinding::setViewModel,
+ transformViewModel = { it as SectionTitleViewModel }
+ ).registerViewDataBinder(
+ viewType = ViewType.VIEW_TYPE_PROMOTED_STORY,
+ inflateDataBinding = RecentlyPlayedStoryCardBinding::inflate,
+ setViewModel = RecentlyPlayedStoryCardBinding::setViewModel,
+ transformViewModel = { it as PromotedStoryViewModel }
+ ).build()
+ }
+
private fun playExploration(
topicId: String,
storyId: String,
@@ -295,13 +174,13 @@ class RecentlyPlayedFragmentPresenter @Inject constructor(
// cases, lessons played from this fragment are known to be in progress, and that progress
// can't be resumed here (hence the restart).
explorationDataController.restartExploration(
- internalProfileId, topicId, storyId, explorationId
+ profileId.internalId, topicId, storyId, explorationId
)
} else {
// The only lessons that can't have their progress saved are those that were already
// completed.
explorationDataController.replayExploration(
- internalProfileId, topicId, storyId, explorationId
+ profileId.internalId, topicId, storyId, explorationId
)
}
startPlayingProvider.toLiveData().observe(fragment) { result ->
@@ -312,7 +191,7 @@ class RecentlyPlayedFragmentPresenter @Inject constructor(
is AsyncResult.Success -> {
oppiaLogger.d("RecentlyPlayedFragment", "Successfully loaded exploration")
routeToExplorationListener.routeToExploration(
- ProfileId.newBuilder().apply { internalId = internalProfileId }.build(),
+ profileId,
topicId,
storyId,
explorationId,
diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt
new file mode 100644
index 00000000000..7716fdf11f5
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt
@@ -0,0 +1,172 @@
+package org.oppia.android.app.home.recentlyplayed
+
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Transformations
+import org.oppia.android.R
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.PromotedActivityList
+import org.oppia.android.app.model.PromotedStory
+import org.oppia.android.app.translation.AppLanguageResourceHandler
+import org.oppia.android.domain.topic.TopicListController
+import org.oppia.android.domain.translation.TranslationController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProviders.Companion.toLiveData
+import org.oppia.android.util.parser.html.StoryHtmlParserEntityType
+import javax.inject.Inject
+
+/** View model for [RecentlyPlayedFragment]. */
+class RecentlyPlayedViewModel private constructor(
+ private val activity: AppCompatActivity,
+ private val topicListController: TopicListController,
+ @StoryHtmlParserEntityType private val entityType: String,
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val translationController: TranslationController,
+ private val promotedStoryClickListener: PromotedStoryClickListener,
+ private val profileId: ProfileId,
+) {
+
+ /** Factory of RecentlyPlayedViewModel. */
+ class Factory @Inject constructor(
+ private val activity: AppCompatActivity,
+ private val topicListController: TopicListController,
+ @StoryHtmlParserEntityType private val entityType: String,
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val translationController: TranslationController,
+ ) {
+
+ /** Creates an instance of [RecentlyPlayedViewModel]. */
+ fun create(
+ promotedStoryClickListener: PromotedStoryClickListener,
+ profileId: ProfileId
+ ): RecentlyPlayedViewModel {
+ return RecentlyPlayedViewModel(
+ activity,
+ topicListController,
+ entityType,
+ resourceHandler,
+ translationController,
+ promotedStoryClickListener,
+ profileId,
+ )
+ }
+ }
+
+ /**
+ * [LiveData] with the list of recently played items for a ProfileId, organized in sections.
+ */
+ val recentlyPlayedItems: LiveData> by lazy {
+ Transformations.map(promotedActivityListLiveData, ::processPromotedStoryList)
+ }
+
+ private val promotedActivityListLiveData: LiveData by lazy {
+ getAssumedSuccessfulPromotedActivityList()
+ }
+
+ private val promotedStoryListSummaryResultLiveData:
+ LiveData>
+ by lazy {
+ topicListController.getPromotedActivityList(profileId).toLiveData()
+ }
+
+ private fun getAssumedSuccessfulPromotedActivityList(): LiveData {
+ return Transformations.map(promotedStoryListSummaryResultLiveData) {
+ when (it) {
+ // If there's an error loading the data, assume the default.
+ is AsyncResult.Failure, is AsyncResult.Pending -> PromotedActivityList.getDefaultInstance()
+ is AsyncResult.Success -> it.value
+ }
+ }
+ }
+
+ private fun processPromotedStoryList(
+ promotedActivityList: PromotedActivityList
+ ): List {
+ val itemList: MutableList = mutableListOf()
+ if (promotedActivityList.promotedStoryList.recentlyPlayedStoryList.isNotEmpty()) {
+ addRecentlyPlayedStoryListSection(
+ promotedActivityList.promotedStoryList.recentlyPlayedStoryList,
+ itemList
+ )
+ }
+
+ if (promotedActivityList.promotedStoryList.olderPlayedStoryList.isNotEmpty()) {
+ addOlderStoryListSection(
+ promotedActivityList.promotedStoryList.olderPlayedStoryList,
+ itemList
+ )
+ }
+
+ if (promotedActivityList.promotedStoryList.suggestedStoryList.isNotEmpty()) {
+ addRecommendedStoryListSection(
+ promotedActivityList.promotedStoryList.suggestedStoryList,
+ itemList
+ )
+ }
+ return itemList
+ }
+
+ private fun addRecentlyPlayedStoryListSection(
+ recentlyPlayedStoryList: MutableList,
+ itemList: MutableList
+ ) {
+ val recentSectionTitleViewModel =
+ SectionTitleViewModel(
+ resourceHandler.getStringInLocale(R.string.ongoing_story_last_week), false
+ )
+ itemList.add(recentSectionTitleViewModel)
+ recentlyPlayedStoryList.forEachIndexed { index, promotedStory ->
+ val ongoingStoryViewModel = createOngoingStoryViewModel(promotedStory, index)
+ itemList.add(ongoingStoryViewModel)
+ }
+ }
+
+ private fun addOlderStoryListSection(
+ olderPlayedStoryList: List,
+ itemList: MutableList
+ ) {
+ val showDivider = itemList.isNotEmpty()
+ val olderSectionTitleViewModel =
+ SectionTitleViewModel(
+ resourceHandler.getStringInLocale(R.string.ongoing_story_last_month),
+ showDivider
+ )
+ itemList.add(olderSectionTitleViewModel)
+ olderPlayedStoryList.forEachIndexed { index, promotedStory ->
+ val ongoingStoryViewModel = createOngoingStoryViewModel(promotedStory, index)
+ itemList.add(ongoingStoryViewModel)
+ }
+ }
+
+ private fun addRecommendedStoryListSection(
+ suggestedStoryList: List,
+ itemList: MutableList
+ ) {
+ val showDivider = itemList.isNotEmpty()
+ val recommendedSectionTitleViewModel =
+ SectionTitleViewModel(
+ resourceHandler.getStringInLocale(R.string.recommended_stories),
+ showDivider
+ )
+ itemList.add(recommendedSectionTitleViewModel)
+ suggestedStoryList.forEachIndexed { index, suggestedStory ->
+ val ongoingStoryViewModel = createOngoingStoryViewModel(suggestedStory, index)
+ itemList.add(ongoingStoryViewModel)
+ }
+ }
+
+ private fun createOngoingStoryViewModel(
+ promotedStory: PromotedStory,
+ index: Int
+ ): RecentlyPlayedItemViewModel {
+ return PromotedStoryViewModel(
+ activity,
+ promotedStory,
+ entityType,
+ promotedStoryClickListener,
+ index,
+ resourceHandler,
+ translationController
+ )
+ }
+}
diff --git a/app/src/main/res/layout/recently_played_fragment.xml b/app/src/main/res/layout/recently_played_fragment.xml
index e4877a7cbc8..3c61fcc166d 100644
--- a/app/src/main/res/layout/recently_played_fragment.xml
+++ b/app/src/main/res/layout/recently_played_fragment.xml
@@ -2,6 +2,13 @@
+
+
+
+
+
+ android:scrollbars="none"
+ app:data="@{viewModel.recentlyPlayedItems}" />
Date: Wed, 12 Jul 2023 11:19:23 +0300
Subject: [PATCH 09/33] Fix #4878: Add NPS Survey Events (#5080)
## Explanation
Fixes #4878.
This is PR 4 of 6 planned PRs.
It adds eventlogs that are used to describe the survey, including
submitting the responses to the compulsory questions of the survey.
The data that needs to be recorded is tracked
[here](https://docs.google.com/spreadsheets/d/1oLP9vd8Lfd_wTUyhSFk0K6d5zhL-pVJEZJpRR-cvfXI/edit?resourcekey=0-Por1i0feHg0jff9Sps__Gw#gid=216732748):
- market_fit: MarketFit Answer
- nps_q1: NPS Score
- nps_user: UserType answer
- nps_served: Survey Popup Shown Event
- nps_abandoned: Abandon Survey Event
- language: App language is automatically added to events by the
EventBundleCreator
- region_locale: Country information is automatically added to events by
the EventBundleCreator
- app_version: automatically added to events by the EventBundleCreator
- time_exposed/time_submitted: event timestamps are automatically added
to events by the AnalyticsController
- lesson_chapter: we're logging both topic and exploration IDs
- user_identifier: This PR adds profileId, although it is not unique.
This should be replaced by a unique ID from Core Metrics in a
fast-follow item.
The remaining data from this spreadsheet are calculated fields that will
be derived from the above parameters.
The nps_q2/ Feedback question is to be uploaded to Firestore on our new
Firestore infrastructure in PR #5003.
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## Log Screenshots
### Event Logs from Developer Menu
| | |
|---|---|
|
![Screenshot_1688920566](https://github.com/oppia/oppia-android/assets/59600948/fbbef767-3929-45cb-a7fc-aff89673b513)|
![Screenshot_1688758093](https://github.com/oppia/oppia-android/assets/59600948/5c3b23b1-81c7-4cde-93b4-c574c37952ba)|
### Event Logs from Firebase Debug View
| | |
|---|---|
|![Screenshot 2023-07-09 at 21 13
20](https://github.com/oppia/oppia-android/assets/59600948/ef611b75-6c4f-4dd7-accd-4399f4cf1754)|![Screenshot
2023-07-09 at 21 14
04](https://github.com/oppia/oppia-android/assets/59600948/ad9d573a-215f-4120-877f-9ec11c8d2ac3)|
|![Screenshot 2023-07-09 at 21 17
08](https://github.com/oppia/oppia-android/assets/59600948/9f4fed24-a71f-45cf-b4e9-e3c6ddbce9d2)|![Screenshot
2023-07-09 at 21 18
25](https://github.com/oppia/oppia-android/assets/59600948/dcf59fb5-7ff7-4347-9a8d-6d9e0c9b6dc1)|
|![Screenshot 2023-07-09 at 22 25
27](https://github.com/oppia/oppia-android/assets/59600948/42687fca-b129-4261-ba9f-f11f0e62a63e)|
![Screenshot 2023-07-09 at 22 26
17](https://github.com/oppia/oppia-android/assets/59600948/ae462442-6ae0-4bff-9f6b-0f4f57017fca)|
---
.../ExplorationActivityPresenter.kt | 7 +-
.../player/state/StateFragmentPresenter.kt | 7 +-
.../ExitSurveyConfirmationDialogFragment.kt | 3 +-
...rveyConfirmationDialogFragmentPresenter.kt | 6 +-
.../android/app/survey/SurveyActivity.kt | 11 +-
.../app/survey/SurveyActivityPresenter.kt | 21 +-
.../android/app/survey/SurveyFragment.kt | 2 +
.../app/survey/SurveyFragmentPresenter.kt | 26 +-
.../SurveyOutroDialogFragmentPresenter.kt | 2 +-
.../app/survey/SurveyWelcomeDialogFragment.kt | 7 +-
.../SurveyWelcomeDialogFragmentPresenter.kt | 28 +-
.../android/app/survey/SurveyActivityTest.kt | 7 +-
.../android/app/survey/SurveyFragmentTest.kt | 25 +-
.../android/domain/oppialogger/OppiaLogger.kt | 34 ++
.../domain/oppialogger/survey/BUILD.bazel | 20 ++
.../oppialogger/survey/SurveyEventsLogger.kt | 98 ++++++
.../oppia/android/domain/survey/BUILD.bazel | 1 +
.../android/domain/survey/SurveyController.kt | 9 +-
.../domain/survey/SurveyProgressController.kt | 116 +++++--
.../domain/survey/SurveyQuestionDeck.kt | 18 +
.../domain/oppialogger/OppiaLoggerTest.kt | 32 +-
.../domain/oppialogger/analytics/BUILD.bazel | 30 ++
.../analytics/SurveyEventsLoggerTest.kt | 191 +++++++++++
.../oppia/android/domain/survey/BUILD.bazel | 1 +
.../domain/survey/SurveyControllerTest.kt | 35 +-
.../survey/SurveyProgressControllerTest.kt | 96 +++++-
model/src/main/proto/BUILD.bazel | 1 +
model/src/main/proto/arguments.proto | 3 +
model/src/main/proto/oppia_logger.proto | 69 ++++
.../testing/logging/EventLogSubject.kt | 310 ++++++++++++++++++
.../util/logging/EventBundleCreator.kt | 67 ++++
...entTypeToHumanReadableNameConverterImpl.kt | 5 +
...entTypeToHumanReadableNameConverterImpl.kt | 5 +
33 files changed, 1206 insertions(+), 87 deletions(-)
create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/survey/BUILD.bazel
create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/survey/SurveyEventsLogger.kt
create mode 100644 domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/SurveyEventsLoggerTest.kt
diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt
index cdef02a2692..e1d7857e85c 100644
--- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt
@@ -529,7 +529,12 @@ class ExplorationActivityPresenter @Inject constructor(
is AsyncResult.Success -> {
if (gatingResult.value) {
val dialogFragment =
- SurveyWelcomeDialogFragment.newInstance(profileId, topicId, SURVEY_QUESTIONS)
+ SurveyWelcomeDialogFragment.newInstance(
+ profileId,
+ topicId,
+ explorationId,
+ SURVEY_QUESTIONS
+ )
val transaction = activity.supportFragmentManager.beginTransaction()
transaction
.add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG)
diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt
index b0c7687ac28..d13a5dca065 100755
--- a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt
@@ -549,7 +549,12 @@ class StateFragmentPresenter @Inject constructor(
is AsyncResult.Success -> {
if (gatingResult.value) {
val dialogFragment =
- SurveyWelcomeDialogFragment.newInstance(profileId, topicId, SURVEY_QUESTIONS)
+ SurveyWelcomeDialogFragment.newInstance(
+ profileId,
+ topicId,
+ explorationId,
+ SURVEY_QUESTIONS
+ )
val transaction = activity.supportFragmentManager.beginTransaction()
transaction
.add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG)
diff --git a/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt
index eef21525cba..9ee7d511b19 100644
--- a/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt
+++ b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt
@@ -63,8 +63,7 @@ class ExitSurveyConfirmationDialogFragment : InjectableDialogFragment() {
return exitSurveyConfirmationDialogFragmentPresenter.handleCreateView(
inflater,
- container,
- profileId
+ container
)
}
diff --git a/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt
index 13dca266290..3d5f3dd66a5 100644
--- a/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt
@@ -6,7 +6,6 @@ import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import org.oppia.android.app.fragment.FragmentScope
-import org.oppia.android.app.model.ProfileId
import org.oppia.android.databinding.SurveyExitConfirmationDialogBinding
import org.oppia.android.domain.oppialogger.OppiaLogger
import org.oppia.android.domain.survey.SurveyController
@@ -28,8 +27,7 @@ class ExitSurveyConfirmationDialogFragmentPresenter @Inject constructor(
/** Sets up data binding. */
fun handleCreateView(
inflater: LayoutInflater,
- container: ViewGroup?,
- profileId: ProfileId
+ container: ViewGroup?
): View {
val binding =
SurveyExitConfirmationDialogBinding.inflate(inflater, container, /* attachToRoot= */ false)
@@ -57,7 +55,7 @@ class ExitSurveyConfirmationDialogFragmentPresenter @Inject constructor(
}
private fun endSurveyWithCallback(callback: () -> Unit) {
- surveyController.stopSurveySession().toLiveData().observe(
+ surveyController.stopSurveySession(surveyCompleted = false).toLiveData().observe(
activity,
{
when (it) {
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt
index 0a486a2959d..c4751b06705 100644
--- a/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt
@@ -15,7 +15,8 @@ import javax.inject.Inject
/** The activity for showing a survey. */
class SurveyActivity : InjectableAutoLocalizedAppCompatActivity() {
- @Inject lateinit var surveyActivityPresenter: SurveyActivityPresenter
+ @Inject
+ lateinit var surveyActivityPresenter: SurveyActivityPresenter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -23,9 +24,9 @@ class SurveyActivity : InjectableAutoLocalizedAppCompatActivity() {
val params = intent.extractParams()
surveyActivityPresenter.handleOnCreate(
- this,
params.profileId,
- params.topicId
+ params.topicId,
+ params.explorationId
)
}
@@ -39,11 +40,13 @@ class SurveyActivity : InjectableAutoLocalizedAppCompatActivity() {
fun createSurveyActivityIntent(
context: Context,
profileId: ProfileId,
- topicId: String
+ topicId: String,
+ explorationId: String
): Intent {
val params = SurveyActivityParams.newBuilder().apply {
this.profileId = profileId
this.topicId = topicId
+ this.explorationId = explorationId
}.build()
return createSurveyActivityIntent(context, params)
}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt
index eea59246707..8020dfe8796 100644
--- a/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt
@@ -1,6 +1,5 @@
package org.oppia.android.app.survey
-import android.content.Context
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
@@ -8,45 +7,35 @@ import org.oppia.android.R
import org.oppia.android.app.activity.ActivityScope
import org.oppia.android.app.model.ProfileId
import org.oppia.android.databinding.SurveyActivityBinding
-import org.oppia.android.domain.oppialogger.OppiaLogger
import javax.inject.Inject
private const val TAG_SURVEY_FRAGMENT = "TAG_SURVEY_FRAGMENT"
const val PROFILE_ID_ARGUMENT_KEY = "profile_id"
const val TOPIC_ID_ARGUMENT_KEY = "topic_id"
+const val EXPLORATION_ID_ARGUMENT_KEY = "exploration_id"
/** The Presenter for [SurveyActivity]. */
@ActivityScope
-class SurveyActivityPresenter @Inject constructor(
- private val activity: AppCompatActivity,
- private val oppiaLogger: OppiaLogger
-) {
- private lateinit var profileId: ProfileId
- private lateinit var topicId: String
- private lateinit var context: Context
-
+class SurveyActivityPresenter @Inject constructor(private val activity: AppCompatActivity) {
private lateinit var binding: SurveyActivityBinding
fun handleOnCreate(
- context: Context,
profileId: ProfileId,
- topicId: String
+ topicId: String,
+ explorationId: String
) {
binding = DataBindingUtil.setContentView(activity, R.layout.survey_activity)
binding.apply {
lifecycleOwner = activity
}
- this.profileId = profileId
- this.topicId = topicId
- this.context = context
-
if (getSurveyFragment() == null) {
val surveyFragment = SurveyFragment()
val args = Bundle()
args.putInt(PROFILE_ID_ARGUMENT_KEY, profileId.internalId)
args.putString(TOPIC_ID_ARGUMENT_KEY, topicId)
+ args.putString(EXPLORATION_ID_ARGUMENT_KEY, explorationId)
surveyFragment.arguments = args
activity.supportFragmentManager.beginTransaction().add(
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt
index aafeea3a082..c6b012fd855 100644
--- a/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt
@@ -53,11 +53,13 @@ class SurveyFragment :
): View? {
val internalProfileId = arguments!!.getInt(PROFILE_ID_ARGUMENT_KEY, -1)
val topicId = arguments!!.getStringFromBundle(TOPIC_ID_ARGUMENT_KEY)!!
+ val explorationId = arguments!!.getStringFromBundle(EXPLORATION_ID_ARGUMENT_KEY)!!
return surveyFragmentPresenter.handleCreateView(
inflater,
container,
internalProfileId,
+ explorationId,
topicId,
this
)
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt
index b88148c7eae..2d2b1ccca0f 100644
--- a/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt
@@ -30,6 +30,7 @@ import org.oppia.android.databinding.SurveyMarketFitQuestionLayoutBinding
import org.oppia.android.databinding.SurveyNpsScoreLayoutBinding
import org.oppia.android.databinding.SurveyUserTypeQuestionLayoutBinding
import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.oppialogger.analytics.AnalyticsController
import org.oppia.android.domain.survey.SurveyProgressController
import org.oppia.android.util.data.AsyncResult
import org.oppia.android.util.data.DataProviders.Companion.toLiveData
@@ -43,30 +44,31 @@ class SurveyFragmentPresenter @Inject constructor(
private val surveyProgressController: SurveyProgressController,
private val surveyViewModel: SurveyViewModel,
private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory,
- private val resourceHandler: AppLanguageResourceHandler
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val analyticsController: AnalyticsController
) {
private val ephemeralQuestionLiveData: LiveData> by lazy {
surveyProgressController.getCurrentQuestion().toLiveData()
}
private lateinit var profileId: ProfileId
- private lateinit var topicId: String
private lateinit var binding: SurveyFragmentBinding
private lateinit var surveyToolbar: Toolbar
private lateinit var answerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver
private lateinit var answerHandler: SelectedAnswerHandler
private lateinit var questionSelectedAnswer: SurveySelectedAnswer
+ private var isCurrentQuestionTerminal: Boolean = false
/** Sets up data binding. */
fun handleCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
internalProfileId: Int,
+ explorationId: String,
topicId: String,
fragment: SurveyFragment
): View? {
profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build()
- this.topicId = topicId
this.answerAvailabilityReceiver = fragment
this.answerHandler = fragment
@@ -101,6 +103,8 @@ class SurveyFragmentPresenter @Inject constructor(
surveyProgressController.moveToPreviousQuestion()
}
+ logBeginSurveyEvent(explorationId, topicId, profileId)
+
subscribeToCurrentQuestion()
return binding.root
@@ -237,6 +241,8 @@ class SurveyFragmentPresenter @Inject constructor(
)
else -> {}
}
+
+ this.isCurrentQuestionTerminal = ephemeralQuestion.terminalQuestion
updateProgress(ephemeralQuestion.currentQuestionIndex, ephemeralQuestion.totalQuestionCount)
updateQuestionText(questionName)
@@ -332,4 +338,18 @@ class SurveyFragmentPresenter @Inject constructor(
InputMethodManager.SHOW_FORCED
)
}
+
+ private fun logBeginSurveyEvent(
+ explorationId: String,
+ topicId: String,
+ profileId: ProfileId
+ ) {
+ analyticsController.logImportantEvent(
+ oppiaLogger.createBeginSurveyContext(
+ explorationId,
+ topicId
+ ),
+ profileId = profileId
+ )
+ }
}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt
index 6ad91d5dde5..8399bb9a0e5 100644
--- a/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt
@@ -54,7 +54,7 @@ class SurveyOutroDialogFragmentPresenter @Inject constructor(
}
private fun endSurveyWithCallback(callback: () -> Unit) {
- surveyController.stopSurveySession().toLiveData().observe(
+ surveyController.stopSurveySession(surveyCompleted = true).toLiveData().observe(
activity,
{
when (it) {
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt
index 528f3b7d9b4..bad65338f3b 100644
--- a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt
@@ -23,6 +23,7 @@ class SurveyWelcomeDialogFragment : InjectableDialogFragment() {
companion object {
internal const val PROFILE_ID_KEY = "SurveyWelcomeDialogFragment.profile_id"
internal const val TOPIC_ID_KEY = "SurveyWelcomeDialogFragment.topic_id"
+ internal const val EXPLORATION_ID_KEY = "SurveyWelcomeDialogFragment.exploration_id"
internal const val MANDATORY_QUESTION_NAMES_KEY = "SurveyWelcomeDialogFragment.question_names"
/**
@@ -34,12 +35,14 @@ class SurveyWelcomeDialogFragment : InjectableDialogFragment() {
fun newInstance(
profileId: ProfileId,
topicId: String,
- mandatoryQuestionNames: List,
+ explorationId: String,
+ mandatoryQuestionNames: List
): SurveyWelcomeDialogFragment {
return SurveyWelcomeDialogFragment().apply {
arguments = Bundle().apply {
putProto(PROFILE_ID_KEY, profileId)
putString(TOPIC_ID_KEY, topicId)
+ putString(EXPLORATION_ID_KEY, explorationId)
putQuestions(MANDATORY_QUESTION_NAMES_KEY, extractQuestions(mandatoryQuestionNames))
}
}
@@ -76,6 +79,7 @@ class SurveyWelcomeDialogFragment : InjectableDialogFragment() {
val profileId = args.getProto(PROFILE_ID_KEY, ProfileId.getDefaultInstance())
val topicId = args.getStringFromBundle(TOPIC_ID_KEY)!!
+ val explorationId = args.getStringFromBundle(EXPLORATION_ID_KEY)!!
val surveyQuestions = args.getQuestions()
return surveyWelcomeDialogFragmentPresenter.handleCreateView(
@@ -83,6 +87,7 @@ class SurveyWelcomeDialogFragment : InjectableDialogFragment() {
container,
profileId,
topicId,
+ explorationId,
surveyQuestions
)
}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt
index 92212cd6dab..da4f62c3ef2 100644
--- a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt
@@ -10,6 +10,7 @@ import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.SurveyQuestionName
import org.oppia.android.databinding.SurveyWelcomeDialogFragmentBinding
import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.oppialogger.analytics.AnalyticsController
import org.oppia.android.domain.survey.SurveyController
import org.oppia.android.util.data.AsyncResult
import org.oppia.android.util.data.DataProviders.Companion.toLiveData
@@ -23,16 +24,21 @@ class SurveyWelcomeDialogFragmentPresenter @Inject constructor(
private val activity: AppCompatActivity,
private val fragment: Fragment,
private val surveyController: SurveyController,
- private val oppiaLogger: OppiaLogger
+ private val oppiaLogger: OppiaLogger,
+ private val analyticsController: AnalyticsController
) {
+ private lateinit var explorationId: String
+
/** Sets up data binding. */
fun handleCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
profileId: ProfileId,
topicId: String,
+ explorationId: String,
questionNames: List,
): View {
+ this.explorationId = explorationId
val binding =
SurveyWelcomeDialogFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
@@ -48,6 +54,8 @@ class SurveyWelcomeDialogFragmentPresenter @Inject constructor(
.commitNow()
}
+ logSurveyPopUpShownEvent(explorationId, topicId, profileId)
+
return binding.root
}
@@ -56,7 +64,7 @@ class SurveyWelcomeDialogFragmentPresenter @Inject constructor(
topicId: String,
questions: List
) {
- val startDataProvider = surveyController.startSurveySession(questions)
+ val startDataProvider = surveyController.startSurveySession(questions, profileId = profileId)
startDataProvider.toLiveData().observe(
activity,
{
@@ -74,7 +82,7 @@ class SurveyWelcomeDialogFragmentPresenter @Inject constructor(
is AsyncResult.Success -> {
oppiaLogger.d("SurveyWelcomeDialogFragment", "Successfully started a survey session")
val intent =
- SurveyActivity.createSurveyActivityIntent(activity, profileId, topicId)
+ SurveyActivity.createSurveyActivityIntent(activity, profileId, topicId, explorationId)
fragment.startActivity(intent)
activity.finish()
val transaction = activity.supportFragmentManager.beginTransaction()
@@ -84,4 +92,18 @@ class SurveyWelcomeDialogFragmentPresenter @Inject constructor(
}
)
}
+
+ private fun logSurveyPopUpShownEvent(
+ explorationId: String,
+ topicId: String,
+ profileId: ProfileId
+ ) {
+ analyticsController.logImportantEvent(
+ oppiaLogger.createShowSurveyPopupContext(
+ explorationId,
+ topicId
+ ),
+ profileId = profileId
+ )
+ }
}
diff --git a/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyActivityTest.kt
index 980d4b0921d..158296d3aa2 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyActivityTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyActivityTest.kt
@@ -62,6 +62,7 @@ import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule
import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule
import org.oppia.android.domain.question.QuestionModule
import org.oppia.android.domain.topic.PrimeTopicAssetsControllerModule
+import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_2
import org.oppia.android.domain.topic.TEST_TOPIC_ID_0
import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule
import org.oppia.android.testing.OppiaTestRule
@@ -147,7 +148,7 @@ class SurveyActivityTest {
@Test
fun testActivity_createIntent_verifyScreenNameInIntent() {
val currentScreenNameWithIntent = SurveyActivity.createSurveyActivityIntent(
- context, profileId, TEST_TOPIC_ID_0
+ context, profileId, TEST_TOPIC_ID_0, TEST_EXPLORATION_ID_2
).extractCurrentAppScreenName()
assertThat(currentScreenNameWithIntent).isEqualTo(ScreenName.SURVEY_ACTIVITY)
@@ -160,9 +161,7 @@ class SurveyActivityTest {
private fun createSurveyActivityIntent(profileId: ProfileId): Intent {
return SurveyActivity.createSurveyActivityIntent(
- context = context,
- profileId = profileId,
- TEST_TOPIC_ID_0
+ context, profileId, TEST_TOPIC_ID_0, TEST_EXPLORATION_ID_2
)
}
diff --git a/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyFragmentTest.kt
index 52ba4de7e87..4ac59d07d5b 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyFragmentTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyFragmentTest.kt
@@ -84,12 +84,14 @@ import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModu
import org.oppia.android.domain.question.QuestionModule
import org.oppia.android.domain.survey.SurveyController
import org.oppia.android.domain.topic.PrimeTopicAssetsControllerModule
+import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_2
import org.oppia.android.domain.topic.TEST_TOPIC_ID_0
import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule
import org.oppia.android.testing.FakeAnalyticsEventLogger
import org.oppia.android.testing.OppiaTestRule
import org.oppia.android.testing.TestLogReportingModule
import org.oppia.android.testing.junit.InitializeDefaultLocaleRule
+import org.oppia.android.testing.logging.EventLogSubject
import org.oppia.android.testing.platformparameter.TestPlatformParameterModule
import org.oppia.android.testing.robolectric.RobolectricModule
import org.oppia.android.testing.threading.TestCoroutineDispatchers
@@ -516,6 +518,23 @@ class SurveyFragmentTest {
}
}
+ @Test
+ fun testSurveyFragment_beginSurvey_logsBeginSurveyEvent() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Verify that the "begin survey" event was logged, and with the correct values.
+ val event = fakeAnalyticsEventLogger.getMostRecentEvent()
+ EventLogSubject.assertThat(event).hasBeginSurveyContextThat {
+ hasExplorationIdThat().isEqualTo(TEST_EXPLORATION_ID_2)
+ hasTopicIdThat().isEqualTo(TEST_TOPIC_ID_0)
+ }
+ }
+ }
+
private fun selectNpsAnswerAndMoveToNextQuestion(npsScore: Int) {
onView(
allOf(
@@ -554,7 +573,8 @@ class SurveyFragmentTest {
SurveyQuestionName.MARKET_FIT,
SurveyQuestionName.NPS
)
- surveyController.startSurveySession(questions)
+ val profileId = ProfileId.newBuilder().setInternalId(1).build()
+ surveyController.startSurveySession(questions, profileId = profileId)
testCoroutineDispatchers.runCurrent()
}
@@ -562,7 +582,8 @@ class SurveyFragmentTest {
return SurveyActivity.createSurveyActivityIntent(
context = context,
profileId = profileId,
- TEST_TOPIC_ID_0
+ TEST_TOPIC_ID_0,
+ TEST_EXPLORATION_ID_2
)
}
diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/OppiaLogger.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/OppiaLogger.kt
index 61055454ff0..bc94cb00df1 100644
--- a/domain/src/main/java/org/oppia/android/domain/oppialogger/OppiaLogger.kt
+++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/OppiaLogger.kt
@@ -216,4 +216,38 @@ class OppiaLogger @Inject constructor(private val consoleLogger: ConsoleLogger)
this.subTopicId = subtopicIndex
}.build()
}
+
+ /**
+ * Returns the context of the event indicating that the user saw the survey popup dialog.
+ */
+ fun createShowSurveyPopupContext(
+ explorationId: String,
+ topicId: String,
+ ): EventLog.Context {
+ return EventLog.Context.newBuilder()
+ .setShowSurveyPopup(
+ EventLog.SurveyContext.newBuilder()
+ .setExplorationId(explorationId)
+ .setTopicId(topicId)
+ .build()
+ )
+ .build()
+ }
+
+ /**
+ * Returns the context of the event indicating that the user began a survey session.
+ */
+ fun createBeginSurveyContext(
+ explorationId: String,
+ topicId: String,
+ ): EventLog.Context {
+ return EventLog.Context.newBuilder()
+ .setBeginSurvey(
+ EventLog.SurveyContext.newBuilder()
+ .setExplorationId(explorationId)
+ .setTopicId(topicId)
+ .build()
+ )
+ .build()
+ }
}
diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/survey/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/oppialogger/survey/BUILD.bazel
new file mode 100644
index 00000000000..1568c5914b9
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/survey/BUILD.bazel
@@ -0,0 +1,20 @@
+"""
+Library for providing logging functionality in a survey.
+"""
+
+load("@dagger//:workspace_defs.bzl", "dagger_rules")
+load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library")
+
+kt_android_library(
+ name = "survey_events_logger",
+ srcs = ["SurveyEventsLogger.kt"],
+ visibility = ["//:oppia_api_visibility"],
+ deps = [
+ "//domain",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger:oppia_logger",
+ "//model/src/main/proto:survey_java_proto_lite",
+ "//third_party:javax_inject_javax_inject",
+ ],
+)
+
+dagger_rules()
diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/survey/SurveyEventsLogger.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/survey/SurveyEventsLogger.kt
new file mode 100644
index 00000000000..7e1178b0ca0
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/survey/SurveyEventsLogger.kt
@@ -0,0 +1,98 @@
+package org.oppia.android.domain.oppialogger.survey
+
+import org.oppia.android.app.model.EventLog
+import org.oppia.android.app.model.MarketFitAnswer
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.UserTypeAnswer
+import org.oppia.android.domain.oppialogger.analytics.AnalyticsController
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Convenience logger for survey events.
+ *
+ * This logger is meant to be used directly in places where survey events have to be logged
+ */
+@Singleton
+class SurveyEventsLogger @Inject constructor(
+ private val analyticsController: AnalyticsController,
+) {
+
+ /**
+ * Logs an event representing a survey session being started and ended before the
+ * mandatory questions are completed.
+ */
+ fun logAbandonSurvey(surveyId: String, profileId: ProfileId, questionName: SurveyQuestionName) {
+ analyticsController.logImportantEvent(
+ createAbandonSurveyContext(surveyId, profileId, questionName),
+ profileId
+ )
+ }
+
+ /** Logs an event representing the responses to the m sandatory survey questions. */
+ fun logMandatoryResponses(
+ surveyId: String,
+ profileId: ProfileId,
+ userTypeAnswer: UserTypeAnswer,
+ marketFitAnswer: MarketFitAnswer,
+ npsScore: Int
+ ) {
+ analyticsController.logImportantEvent(
+ createMandatorySurveyResponseContext(
+ surveyId,
+ profileId,
+ userTypeAnswer,
+ marketFitAnswer,
+ npsScore
+ ),
+ profileId
+ )
+ }
+
+ private fun createMandatorySurveyResponseContext(
+ surveyId: String,
+ profileId: ProfileId,
+ userTypeAnswer: UserTypeAnswer,
+ marketFitAnswer: MarketFitAnswer,
+ npsScore: Int
+ ): EventLog.Context {
+ return EventLog.Context.newBuilder()
+ .setMandatoryResponse(
+ EventLog.MandatorySurveyResponseContext.newBuilder()
+ .setUserTypeAnswer(userTypeAnswer)
+ .setMarketFitAnswer(marketFitAnswer)
+ .setNpsScoreAnswer(npsScore)
+ .setSurveyDetails(
+ createSurveyResponseContext(surveyId, profileId)
+ )
+ )
+ .build()
+ }
+
+ private fun createAbandonSurveyContext(
+ surveyId: String,
+ profileId: ProfileId,
+ questionName: SurveyQuestionName
+ ): EventLog.Context {
+ return EventLog.Context.newBuilder()
+ .setAbandonSurvey(
+ EventLog.AbandonSurveyContext.newBuilder()
+ .setQuestionName(questionName)
+ .setSurveyDetails(
+ createSurveyResponseContext(surveyId, profileId)
+ )
+ )
+ .build()
+ }
+
+ private fun createSurveyResponseContext(
+ surveyId: String,
+ profileId: ProfileId
+ ): EventLog.SurveyResponseContext {
+ return EventLog.SurveyResponseContext.newBuilder()
+ .setProfileId(profileId.internalId.toString())
+ .setSurveyId(surveyId)
+ .build()
+ }
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/survey/BUILD.bazel
index d328aa24c0c..7e6da66527e 100644
--- a/domain/src/main/java/org/oppia/android/domain/survey/BUILD.bazel
+++ b/domain/src/main/java/org/oppia/android/domain/survey/BUILD.bazel
@@ -56,6 +56,7 @@ kt_android_library(
],
visibility = ["//:oppia_api_visibility"],
deps = [
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger/survey:survey_events_logger",
"//model/src/main/proto:survey_java_proto_lite",
],
)
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt
index 06a11a82107..13ee41e96c9 100644
--- a/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt
@@ -1,5 +1,6 @@
package org.oppia.android.domain.survey
+import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.Survey
import org.oppia.android.app.model.SurveyQuestion
import org.oppia.android.app.model.SurveyQuestionName
@@ -41,7 +42,8 @@ class SurveyController @Inject constructor(
*/
fun startSurveySession(
mandatoryQuestionNames: List,
- showOptionalQuestion: Boolean = true
+ showOptionalQuestion: Boolean = true,
+ profileId: ProfileId
): DataProvider {
return try {
val createSurveyDataProvider =
@@ -54,7 +56,7 @@ class SurveyController @Inject constructor(
}
val beginSessionDataProvider =
- surveyProgressController.beginSurveySession(questionsListDataProvider)
+ surveyProgressController.beginSurveySession(surveyId, profileId, questionsListDataProvider)
beginSessionDataProvider.combineWith(
createSurveyDataProvider, START_SURVEY_SESSION_PROVIDER_ID
@@ -122,5 +124,6 @@ class SurveyController @Inject constructor(
* will be reset to 'pending' when a session is currently active, or before any session has
* started.
*/
- fun stopSurveySession(): DataProvider = surveyProgressController.endSurveySession()
+ fun stopSurveySession(surveyCompleted: Boolean): DataProvider =
+ surveyProgressController.endSurveySession(surveyCompleted)
}
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt
index c38bcf713b6..b7a612b2c1a 100644
--- a/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt
@@ -9,12 +9,16 @@ import kotlinx.coroutines.channels.actor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oppia.android.app.model.EphemeralSurveyQuestion
+import org.oppia.android.app.model.MarketFitAnswer
+import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.SelectedAnswerDatabase
import org.oppia.android.app.model.SurveyQuestion
import org.oppia.android.app.model.SurveyQuestionName
import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.model.UserTypeAnswer
import org.oppia.android.data.persistence.PersistentCacheStore
import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController
+import org.oppia.android.domain.oppialogger.survey.SurveyEventsLogger
import org.oppia.android.util.data.AsyncResult
import org.oppia.android.util.data.DataProvider
import org.oppia.android.util.data.DataProviders
@@ -64,8 +68,13 @@ class SurveyProgressController @Inject constructor(
private val dataProviders: DataProviders,
private val exceptionsController: ExceptionsController,
@BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher,
- cacheStoreFactory: PersistentCacheStore.Factory
+ cacheStoreFactory: PersistentCacheStore.Factory,
+ private val surveyLogger: SurveyEventsLogger
) {
+ // TODO(#606): Replace this with a profile scope.
+ private lateinit var profileId: ProfileId
+ private lateinit var surveyId: String
+
private var mostRecentSessionId: String? = null
private val activeSessionId: String
get() = mostRecentSessionId ?: DEFAULT_SESSION_ID
@@ -100,6 +109,8 @@ class SurveyProgressController @Inject constructor(
* whether the start was successful.
*/
fun beginSurveySession(
+ surveyId: String,
+ profileId: ProfileId,
questionsListDataProvider: DataProvider>
): DataProvider {
val ephemeralQuestionFlow = createAsyncResultStateFlow()
@@ -116,6 +127,8 @@ class SurveyProgressController @Inject constructor(
ControllerMessage.InitializeController(
ephemeralQuestionFlow, sessionId, beginSessionResultFlow
)
+ this.profileId = profileId
+ this.surveyId = surveyId
sendCommandForOperation(initializeMessage) {
"Failed to schedule command for initializing the survey progress controller."
}
@@ -228,11 +241,15 @@ class SurveyProgressController @Inject constructor(
* Ends the current survey session and returns a [DataProvider] that indicates whether it was
* successfully ended.
*
- * This method does not actually need to be called when a session is over. Calling it ensures all
- * other [DataProvider]s reset to a correct out-of-session state, but subsequent calls to
- * [beginSurveySession] will reset the session.
+ * This method must be called to explicitly notify the controller that the survey session is being
+ * stopped, in order to maybe save the responses.
+ *
+ * @param surveyCompleted whether this finish action indicates that the survey was fully completed by
+ * the user.
*/
- fun endSurveySession(): DataProvider {
+ fun endSurveySession(
+ surveyCompleted: Boolean
+ ): DataProvider {
// Reset the base questions list provider so that the ephemeral question has no question list to
// reference (since the session finished).
monitoredQuestionListDataProvider.setBaseDataProvider(createEmptyQuestionsListDataProvider()) {
@@ -240,8 +257,7 @@ class SurveyProgressController @Inject constructor(
}
val endSessionResultFlow = createAsyncResultStateFlow()
val message = ControllerMessage.FinishSurveySession(
- activeSessionId,
- endSessionResultFlow
+ surveyCompleted, activeSessionId, endSessionResultFlow
)
sendCommandForOperation(message) {
"Failed to schedule command for finishing the survey session."
@@ -296,9 +312,7 @@ class SurveyProgressController @Inject constructor(
controllerState.handleUpdatedQuestionsList(message.questionsList)
is ControllerMessage.FinishSurveySession -> {
try {
- controllerState.completeSurveyImpl(
- message.callbackFlow
- )
+ controllerState.completeSurveyImpl(message.surveyCompleted, message.callbackFlow)
} finally {
// Ensure the actor ends since the session requires no further message processing.
break
@@ -355,16 +369,6 @@ class SurveyProgressController @Inject constructor(
}
}
- private suspend fun ControllerState.completeSurveyImpl(
- endSessionResultFlow: MutableStateFlow>
- ) {
- checkNotNull(this) { "Cannot stop a survey session which wasn't started." }
- tryOperation(endSessionResultFlow) {
- answerDataStore.clearCacheAsync()
- progress.advancePlayStageTo(SurveyProgress.SurveyStage.NOT_IN_SURVEY_SESSION)
- }
- }
-
private suspend fun ControllerState.submitAnswerImpl(
submitAnswerResultFlow: MutableStateFlow>,
selectedAnswer: SurveySelectedAnswer
@@ -391,11 +395,12 @@ class SurveyProgressController @Inject constructor(
}
}
- private fun saveSelectedAnswer(questionId: String, answer: SurveySelectedAnswer) {
+ private fun ControllerState.saveSelectedAnswer(questionId: String, answer: SurveySelectedAnswer) {
val deferred = recordSelectedAnswerAsync(questionId, answer)
deferred.invokeOnCompletion {
if (it == null) {
+ progress.questionDeck.trackAnsweredQuestions(answer.questionName)
deferred.getCompleted()
} else {
RecordResponseActionStatus.FAILED_TO_SAVE_RESPONSE
@@ -448,6 +453,17 @@ class SurveyProgressController @Inject constructor(
}
}
+ private suspend fun ControllerState.completeSurveyImpl(
+ surveyCompleted: Boolean,
+ endSessionResultFlow: MutableStateFlow>
+ ) {
+ checkNotNull(this) { "Cannot stop a survey session which wasn't started." }
+ tryOperation(endSessionResultFlow) {
+ progress.advancePlayStageTo(SurveyProgress.SurveyStage.NOT_IN_SURVEY_SESSION)
+ finishSurveyAndLog(surveyCompleted)
+ }
+ }
+
private fun createAsyncResultStateFlow(initialValue: AsyncResult = AsyncResult.Pending()) =
MutableStateFlow(initialValue)
@@ -457,6 +473,63 @@ class SurveyProgressController @Inject constructor(
convertAsyncToAutomaticDataProvider("${baseId}_$activeSessionId")
}
+ private suspend fun ControllerState.finishSurveyAndLog(surveyIsComplete: Boolean) {
+ when {
+ surveyIsComplete -> {
+ surveyLogger.logMandatoryResponses(
+ surveyId,
+ profileId,
+ getStoredResponse(SurveyQuestionName.USER_TYPE)!!,
+ getStoredResponse(SurveyQuestionName.MARKET_FIT)!!,
+ getStoredResponse(SurveyQuestionName.NPS)!!
+ )
+
+ // TODO(#5001): Log the optional question response to Firestore
+ }
+ progress.questionDeck.hasAnsweredAllMandatoryQuestions() -> {
+ surveyLogger.logMandatoryResponses(
+ surveyId,
+ profileId,
+ getStoredResponse(SurveyQuestionName.USER_TYPE)!!,
+ getStoredResponse(SurveyQuestionName.MARKET_FIT)!!,
+ getStoredResponse(SurveyQuestionName.NPS)!!
+ )
+ }
+ else -> {
+ val currentQuestionName = progress.questionGraph
+ .getQuestion(progress.questionDeck.getTopQuestionIndex())
+ .questionName
+
+ surveyLogger.logAbandonSurvey(
+ surveyId,
+ profileId,
+ currentQuestionName
+ )
+ }
+ }
+ answerDataStore.clearCacheAsync()
+ }
+
+ private suspend inline fun getStoredResponse(
+ questionName: SurveyQuestionName
+ ): T? {
+ val answerDatabase = answerDataStore.readDataAsync().await()
+ val savedAnswer =
+ answerDatabase.selectedAnswerMap.values.find { it.questionName == questionName }
+ return savedAnswer?.let { getAnswerTypeCase(it) }
+ }
+
+ private inline fun getAnswerTypeCase(surveyAnswer: SurveySelectedAnswer): T? {
+ return when {
+ surveyAnswer.userType != null && T::class == UserTypeAnswer::class ->
+ surveyAnswer.userType as? T
+ surveyAnswer.marketFit != null && T::class == MarketFitAnswer::class ->
+ surveyAnswer.marketFit as? T
+ surveyAnswer.npsScore != null && T::class == Int::class -> surveyAnswer.npsScore as? T
+ else -> null
+ }
+ }
+
/**
* Represents a message that can be sent to [mostRecentCommandQueue] to process changes to
* [ControllerState] (since all changes must be synchronized).
@@ -487,6 +560,7 @@ class SurveyProgressController @Inject constructor(
/** [ControllerMessage] for ending the current survey session. */
data class FinishSurveySession(
+ val surveyCompleted: Boolean,
override val sessionId: String,
override val callbackFlow: MutableStateFlow>
) : ControllerMessage()
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt
index 2b7de9d6aa5..5add7977b2d 100644
--- a/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt
@@ -2,6 +2,7 @@ package org.oppia.android.domain.survey
import org.oppia.android.app.model.EphemeralSurveyQuestion
import org.oppia.android.app.model.SurveyQuestion
+import org.oppia.android.app.model.SurveyQuestionName
/**
* Tracks the dynamic behavior of the user through a survey session. This class
@@ -15,6 +16,7 @@ class SurveyQuestionDeck constructor(
private var pendingTopQuestion = initialQuestion
private var viewedQuestionsCount: Int = 0
private var questionIndex: Int = 0
+ private val answeredQuestions = mutableListOf()
/** Sets this deck to a specific question. */
fun updateDeck(pendingTopQuestion: SurveyQuestion) {
@@ -91,4 +93,20 @@ class SurveyQuestionDeck constructor(
private fun isTopOfDeckTerminal(): Boolean {
return isTopOfDeckTerminalChecker(pendingTopQuestion)
}
+
+ /** Stores a list of all the questions that have been answered in the survey. */
+ fun trackAnsweredQuestions(questionName: SurveyQuestionName) {
+ answeredQuestions.add(questionName)
+ }
+
+ /**
+ * Returns whether the user has answered all the mandatory questions, which indicate partial
+ * survey completion.
+ *
+ * The user must have answered the [SurveyQuestionName.NPS] question for the survey to be
+ * considered partially completed.
+ */
+ fun hasAnsweredAllMandatoryQuestions(): Boolean {
+ return answeredQuestions.contains(SurveyQuestionName.NPS)
+ }
}
diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/OppiaLoggerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/OppiaLoggerTest.kt
index 077ac2cc14c..bf940cfe8e1 100644
--- a/domain/src/test/java/org/oppia/android/domain/oppialogger/OppiaLoggerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/OppiaLoggerTest.kt
@@ -13,6 +13,7 @@ import dagger.Provides
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.BEGIN_SURVEY
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.CLOSE_REVISION_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_CONCEPT_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_EXPLORATION_ACTIVITY
@@ -25,11 +26,11 @@ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_QUE
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_TAB
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_STORY_ACTIVITY
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SHOW_SURVEY_POPUP
import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule
import org.oppia.android.testing.FakeAnalyticsEventLogger
import org.oppia.android.testing.TestLogReportingModule
-import org.oppia.android.testing.logging.EventLogSubject.Companion.assertThat
import org.oppia.android.testing.robolectric.RobolectricModule
import org.oppia.android.testing.threading.TestDispatcherModule
import org.oppia.android.testing.time.FakeOppiaClock
@@ -99,9 +100,14 @@ class OppiaLoggerTest {
private val TEST_ERROR_EXCEPTION = Throwable(TEST_ERROR_LOG_EXCEPTION)
}
- @Inject lateinit var oppiaLogger: OppiaLogger
- @Inject lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
- @Inject lateinit var fakeOppiaClock: FakeOppiaClock
+ @Inject
+ lateinit var oppiaLogger: OppiaLogger
+
+ @Inject
+ lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
+
+ @Inject
+ lateinit var fakeOppiaClock: FakeOppiaClock
@Before
fun setUp() {
@@ -315,6 +321,24 @@ class OppiaLoggerTest {
assertThat(eventContext.closeRevisionCard.subTopicId).isEqualTo(TEST_SUB_TOPIC_ID)
}
+ @Test
+ fun testController_createShowSurveyPopupContext_returnsCorrectShowSurveyPopupContext() {
+ val eventContext = oppiaLogger.createShowSurveyPopupContext(TEST_EXPLORATION_ID, TEST_TOPIC_ID)
+
+ assertThat(eventContext.activityContextCase).isEqualTo(SHOW_SURVEY_POPUP)
+ assertThat(eventContext.showSurveyPopup.topicId).matches(TEST_TOPIC_ID)
+ assertThat(eventContext.showSurveyPopup.explorationId).isEqualTo(TEST_EXPLORATION_ID)
+ }
+
+ @Test
+ fun testController_createBeginSurveyContext_returnsCorrectBeginSurveyContext() {
+ val eventContext = oppiaLogger.createBeginSurveyContext(TEST_EXPLORATION_ID, TEST_TOPIC_ID)
+
+ assertThat(eventContext.activityContextCase).isEqualTo(BEGIN_SURVEY)
+ assertThat(eventContext.beginSurvey.topicId).matches(TEST_TOPIC_ID)
+ assertThat(eventContext.beginSurvey.explorationId).isEqualTo(TEST_EXPLORATION_ID)
+ }
+
private fun setUpTestApplicationComponent() {
DaggerOppiaLoggerTest_TestApplicationComponent.builder()
.setApplication(ApplicationProvider.getApplicationContext())
diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel
index 346231f3738..12886f46df6 100644
--- a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel
+++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel
@@ -259,4 +259,34 @@ oppia_android_test(
],
)
+oppia_android_test(
+ name = "SurveyEventsLoggerTest",
+ srcs = ["SurveyEventsLoggerTest.kt"],
+ custom_package = "org.oppia.android.domain.oppialogger.analytics",
+ test_class = "org.oppia.android.domain.oppialogger.analytics.SurveyEventsLoggerTest",
+ test_manifest = "//domain:test_manifest",
+ deps = [
+ ":dagger",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger/survey:survey_events_logger",
+ "//testing",
+ "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor",
+ "//testing/src/main/java/org/oppia/android/testing/logging:event_log_subject",
+ "//testing/src/main/java/org/oppia/android/testing/logging:sync_status_test_module",
+ "//testing/src/main/java/org/oppia/android/testing/platformparameter:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/threading:test_coroutine_dispatchers",
+ "//testing/src/main/java/org/oppia/android/testing/threading:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/time:test_module",
+ "//third_party:androidx_test_ext_junit",
+ "//third_party:com_google_truth_truth",
+ "//third_party:junit_junit",
+ "//third_party:org_robolectric_robolectric",
+ "//third_party:robolectric_android-all",
+ "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module",
+ "//utility/src/main/java/org/oppia/android/util/locale:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/networking:debug_module",
+ ],
+)
+
dagger_rules()
diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/SurveyEventsLoggerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/SurveyEventsLoggerTest.kt
new file mode 100644
index 00000000000..4a5e3723f33
--- /dev/null
+++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/SurveyEventsLoggerTest.kt
@@ -0,0 +1,191 @@
+package org.oppia.android.domain.oppialogger.analytics
+
+import android.app.Application
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.BindsInstance
+import dagger.Component
+import dagger.Module
+import dagger.Provides
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.app.model.MarketFitAnswer
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.UserTypeAnswer
+import org.oppia.android.domain.oppialogger.EventLogStorageCacheSize
+import org.oppia.android.domain.oppialogger.ExceptionLogStorageCacheSize
+import org.oppia.android.domain.oppialogger.LoggingIdentifierModule
+import org.oppia.android.domain.oppialogger.survey.SurveyEventsLogger
+import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule
+import org.oppia.android.testing.FakeAnalyticsEventLogger
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.logging.EventLogSubject.Companion.assertThat
+import org.oppia.android.testing.logging.SyncStatusTestModule
+import org.oppia.android.testing.platformparameter.TestPlatformParameterModule
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.testing.time.FakeOppiaClockModule
+import org.oppia.android.util.caching.AssetModule
+import org.oppia.android.util.data.DataProvidersInjector
+import org.oppia.android.util.data.DataProvidersInjectorProvider
+import org.oppia.android.util.locale.LocaleProdModule
+import org.oppia.android.util.logging.EnableConsoleLog
+import org.oppia.android.util.logging.EnableFileLog
+import org.oppia.android.util.logging.GlobalLogLevel
+import org.oppia.android.util.logging.LogLevel
+import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [SurveyEventsLogger]. */
+// FunctionName: test names are conventionally named with underscores.
+@Suppress("FunctionName")
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(application = SurveyEventsLoggerTest.TestApplication::class)
+class SurveyEventsLoggerTest {
+ private companion object {
+ private const val TEST_SURVEY_ID = "test_survey_id"
+ }
+
+ @Inject
+ lateinit var surveyEventsLogger: SurveyEventsLogger
+
+ @Inject
+ lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ private val profileId by lazy { ProfileId.newBuilder().apply { internalId = 0 }.build() }
+
+ @Before
+ fun setUp() {
+ setUpTestApplicationComponent()
+ }
+
+ @Test
+ fun testLogAbandonSurvey_logsEventWithCorrectValues() {
+ surveyEventsLogger.logAbandonSurvey(TEST_SURVEY_ID, profileId, SurveyQuestionName.MARKET_FIT)
+ testCoroutineDispatchers.runCurrent()
+
+ val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent()
+
+ assertThat(eventLog).hasAbandonSurveyContextThat {
+ hasSurveyDetailsThat {
+ hasSurveyIdThat().isEqualTo(TEST_SURVEY_ID)
+ hasInternalProfileIdThat().isEqualTo("0")
+ }
+ hasQuestionNameThat().isEqualTo(SurveyQuestionName.MARKET_FIT)
+ }
+ }
+
+ @Test
+ fun testLogMandatoryResponses_logsEventWithCorrectValues() {
+ surveyEventsLogger.logMandatoryResponses(
+ TEST_SURVEY_ID,
+ profileId,
+ UserTypeAnswer.LEARNER,
+ MarketFitAnswer.DISAPPOINTED,
+ npsScore = 8
+ )
+ testCoroutineDispatchers.runCurrent()
+
+ val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent()
+
+ assertThat(eventLog).hasMandatorySurveyResponseContextThat {
+ hasSurveyDetailsThat {
+ hasSurveyIdThat().isNotEmpty()
+ hasInternalProfileIdThat().isEqualTo("0")
+ }
+ hasUserTypeAnswerThat().isEqualTo(UserTypeAnswer.LEARNER)
+ hasMarketFitAnswerThat().isEqualTo(MarketFitAnswer.DISAPPOINTED)
+ hasNpsScoreAnswerThat().isEqualTo(8)
+ }
+ }
+
+ private fun setUpTestApplicationComponent() {
+ DaggerSurveyEventsLoggerTest_TestApplicationComponent.builder()
+ .setApplication(ApplicationProvider.getApplicationContext())
+ .build()
+ .inject(this)
+ }
+
+ // TODO(#89): Move this to a common test application component.
+ @Module
+ class TestModule {
+ @Provides
+ @Singleton
+ fun provideContext(application: Application): Context {
+ return application
+ }
+
+ // TODO(#59): Either isolate these to their own shared test module, or use the real logging
+ // module in tests to avoid needing to specify these settings for tests.
+ @EnableConsoleLog
+ @Provides
+ fun provideEnableConsoleLog(): Boolean = true
+
+ @EnableFileLog
+ @Provides
+ fun provideEnableFileLog(): Boolean = false
+
+ @GlobalLogLevel
+ @Provides
+ fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE
+ }
+
+ @Module
+ class TestLogStorageModule {
+ @Provides
+ @EventLogStorageCacheSize
+ fun provideEventLogStorageCacheSize(): Int = 2
+
+ @Provides
+ @ExceptionLogStorageCacheSize
+ fun provideExceptionLogStorageCacheSize(): Int = 2
+ }
+
+ // TODO(#89): Move this to a common test application component.
+ @Singleton
+ @Component(
+ modules = [
+ TestModule::class, TestLogReportingModule::class, RobolectricModule::class,
+ TestDispatcherModule::class, TestLogStorageModule::class,
+ NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, FakeOppiaClockModule::class,
+ TestPlatformParameterModule::class, PlatformParameterSingletonModule::class,
+ LoggingIdentifierModule::class, SyncStatusTestModule::class,
+ ApplicationLifecycleModule::class, AssetModule::class
+ ]
+ )
+ interface TestApplicationComponent : DataProvidersInjector {
+ @Component.Builder
+ interface Builder {
+ @BindsInstance
+ fun setApplication(application: Application): Builder
+ fun build(): TestApplicationComponent
+ }
+
+ fun inject(test: SurveyEventsLoggerTest)
+ }
+
+ class TestApplication : Application(), DataProvidersInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerSurveyEventsLoggerTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build()
+ }
+
+ fun inject(test: SurveyEventsLoggerTest) {
+ component.inject(test)
+ }
+
+ override fun getDataProvidersInjector(): DataProvidersInjector = component
+ }
+}
diff --git a/domain/src/test/java/org/oppia/android/domain/survey/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/survey/BUILD.bazel
index 4c93d8d7794..d2d4394ce79 100644
--- a/domain/src/test/java/org/oppia/android/domain/survey/BUILD.bazel
+++ b/domain/src/test/java/org/oppia/android/domain/survey/BUILD.bazel
@@ -72,6 +72,7 @@ oppia_android_test(
"//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module",
"//domain/src/main/java/org/oppia/android/domain/survey:survey_controller",
"//testing",
+ "//testing/src/main/java/org/oppia/android/testing/logging:event_log_subject",
"//testing/src/main/java/org/oppia/android/testing/robolectric:test_module",
"//testing/src/main/java/org/oppia/android/testing/threading:test_module",
"//testing/src/main/java/org/oppia/android/testing/time:test_module",
diff --git a/domain/src/test/java/org/oppia/android/domain/survey/SurveyControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/survey/SurveyControllerTest.kt
index de0cf64e4bb..eb9e43ab83a 100644
--- a/domain/src/test/java/org/oppia/android/domain/survey/SurveyControllerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/survey/SurveyControllerTest.kt
@@ -12,6 +12,7 @@ import dagger.Provides
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
+import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.SurveyQuestionName
import org.oppia.android.domain.exploration.ExplorationProgressModule
import org.oppia.android.domain.oppialogger.ApplicationIdSeed
@@ -34,7 +35,8 @@ import org.oppia.android.util.logging.GlobalLogLevel
import org.oppia.android.util.logging.LogLevel
import org.oppia.android.util.logging.SyncStatusModule
import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
-import org.oppia.android.util.platformparameter.LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE
+import org.oppia.android.util.platformparameter.EnableLearnerStudyAnalytics
+import org.oppia.android.util.platformparameter.PlatformParameterValue
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
import javax.inject.Inject
@@ -60,11 +62,12 @@ class SurveyControllerTest {
@Inject
lateinit var surveyProgressController: SurveyProgressController
- val questions = listOf(
+ private val questions = listOf(
SurveyQuestionName.USER_TYPE,
SurveyQuestionName.MARKET_FIT,
SurveyQuestionName.NPS
)
+ private val profileId = ProfileId.newBuilder().setInternalId(1).build()
@Before
fun setUp() {
@@ -74,14 +77,14 @@ class SurveyControllerTest {
@Test
fun testController_startSurveySession_succeeds() {
val surveyDataProvider =
- surveyController.startSurveySession(questions)
+ surveyController.startSurveySession(questions, profileId = profileId)
monitorFactory.waitForNextSuccessfulResult(surveyDataProvider)
}
@Test
fun testController_startSurveySession_sessionStartsWithInitialQuestion() {
- surveyController.startSurveySession(questions)
+ surveyController.startSurveySession(questions, profileId = profileId)
val result = surveyProgressController.getCurrentQuestion()
val ephemeralQuestion = monitorFactory.waitForNextSuccessfulResult(result)
@@ -91,7 +94,7 @@ class SurveyControllerTest {
@Test
fun testStartSurveySession_withTwoQuestions_showOptionalQuestion_succeeds() {
val mandatoryQuestionNameList = listOf(SurveyQuestionName.NPS)
- surveyController.startSurveySession(mandatoryQuestionNameList)
+ surveyController.startSurveySession(mandatoryQuestionNameList, profileId = profileId)
val result = surveyProgressController.getCurrentQuestion()
val ephemeralQuestion = monitorFactory.waitForNextSuccessfulResult(result)
@@ -103,7 +106,8 @@ class SurveyControllerTest {
val mandatoryQuestionNameList = listOf(SurveyQuestionName.MARKET_FIT, SurveyQuestionName.NPS)
surveyController.startSurveySession(
mandatoryQuestionNames = mandatoryQuestionNameList,
- showOptionalQuestion = false
+ showOptionalQuestion = false,
+ profileId = profileId
)
val result = surveyProgressController.getCurrentQuestion()
@@ -116,7 +120,8 @@ class SurveyControllerTest {
val mandatoryQuestionNameList = listOf()
surveyController.startSurveySession(
mandatoryQuestionNames = mandatoryQuestionNameList,
- showOptionalQuestion = true
+ showOptionalQuestion = true,
+ profileId = profileId
)
val result = surveyProgressController.getCurrentQuestion()
@@ -131,7 +136,8 @@ class SurveyControllerTest {
val mandatoryQuestionNameList = listOf(SurveyQuestionName.NPS)
surveyController.startSurveySession(
mandatoryQuestionNames = mandatoryQuestionNameList,
- showOptionalQuestion = false
+ showOptionalQuestion = false,
+ profileId = profileId
)
val result = surveyProgressController.getCurrentQuestion()
@@ -143,7 +149,7 @@ class SurveyControllerTest {
@Test
fun testStopSurveySession_withoutStartingSession_returnsFailure() {
- val stopProvider = surveyController.stopSurveySession()
+ val stopProvider = surveyController.stopSurveySession(true)
// The operation should be failing since the session hasn't started.
val result = monitorFactory.waitForNextFailureResult(stopProvider)
@@ -159,10 +165,6 @@ class SurveyControllerTest {
@Module
class TestModule {
- internal companion object {
- var enableLearnerStudyAnalytics = LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE
- }
-
@Provides
@Singleton
fun provideContext(application: Application): Context {
@@ -181,6 +183,13 @@ class SurveyControllerTest {
@GlobalLogLevel
@Provides
fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE
+
+ @Provides
+ @EnableLearnerStudyAnalytics
+ fun provideLearnerStudyAnalytics(): PlatformParameterValue {
+ // Enable the study by default in tests.
+ return PlatformParameterValue.createDefaultParameter(defaultValue = true)
+ }
}
@Module
diff --git a/domain/src/test/java/org/oppia/android/domain/survey/SurveyProgressControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/survey/SurveyProgressControllerTest.kt
index 79f3cdcb1d4..865120fb089 100644
--- a/domain/src/test/java/org/oppia/android/domain/survey/SurveyProgressControllerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/survey/SurveyProgressControllerTest.kt
@@ -14,6 +14,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.oppia.android.app.model.EphemeralSurveyQuestion
import org.oppia.android.app.model.MarketFitAnswer
+import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.SurveyQuestionName
import org.oppia.android.app.model.SurveySelectedAnswer
import org.oppia.android.app.model.UserTypeAnswer
@@ -21,9 +22,11 @@ import org.oppia.android.domain.exploration.ExplorationProgressModule
import org.oppia.android.domain.oppialogger.ApplicationIdSeed
import org.oppia.android.domain.oppialogger.LogStorageModule
import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
+import org.oppia.android.testing.FakeAnalyticsEventLogger
import org.oppia.android.testing.FakeExceptionLogger
import org.oppia.android.testing.TestLogReportingModule
import org.oppia.android.testing.data.DataProviderTestMonitor
+import org.oppia.android.testing.logging.EventLogSubject
import org.oppia.android.testing.robolectric.RobolectricModule
import org.oppia.android.testing.threading.TestCoroutineDispatchers
import org.oppia.android.testing.threading.TestDispatcherModule
@@ -38,7 +41,9 @@ import org.oppia.android.util.logging.GlobalLogLevel
import org.oppia.android.util.logging.LogLevel
import org.oppia.android.util.logging.SyncStatusModule
import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.oppia.android.util.platformparameter.EnableLearnerStudyAnalytics
import org.oppia.android.util.platformparameter.LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE
+import org.oppia.android.util.platformparameter.PlatformParameterValue
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
import javax.inject.Inject
@@ -64,6 +69,11 @@ class SurveyProgressControllerTest {
@Inject
lateinit var surveyProgressController: SurveyProgressController
+ @Inject
+ lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
+
+ private val profileId = ProfileId.newBuilder().setInternalId(1).build()
+
@Before
fun setUp() {
setUpTestApplicationComponent()
@@ -72,7 +82,7 @@ class SurveyProgressControllerTest {
@Test
fun testStartSurveySession_succeeds() {
val surveyDataProvider =
- surveyController.startSurveySession(questions)
+ surveyController.startSurveySession(questions, profileId = profileId)
monitorFactory.waitForNextSuccessfulResult(surveyDataProvider)
}
@@ -307,7 +317,7 @@ class SurveyProgressControllerTest {
@Test
fun testStopSurveySession_withoutStartingSession_returnsFailure() {
- val stopProvider = surveyController.stopSurveySession()
+ val stopProvider = surveyController.stopSurveySession(surveyCompleted = true)
// The operation should be failing since the session hasn't started.
val result = monitorFactory.waitForNextFailureResult(stopProvider)
@@ -319,13 +329,84 @@ class SurveyProgressControllerTest {
@Test
fun testStopSurveySession_afterStartingPreviousSession_succeeds() {
startSuccessfulSurveySession()
- val stopProvider = surveyController.stopSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ val stopProvider = surveyController.stopSurveySession(surveyCompleted = false)
+ monitorFactory.waitForNextSuccessfulResult(stopProvider)
+ }
+
+ @Test
+ fun testEndSurvey_beforeCompletingMandatoryQuestions_logsAbandonSurveyEvent() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ // Submit and navigate to NPS question
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+ stopSurveySession(surveyCompleted = false)
+
+ val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent()
+ EventLogSubject.assertThat(eventLog).hasAbandonSurveyContextThat {
+ hasSurveyDetailsThat {
+ hasSurveyIdThat().isNotEmpty()
+ hasInternalProfileIdThat().isEqualTo("1")
+ }
+ hasQuestionNameThat().isEqualTo(SurveyQuestionName.NPS)
+ }
+ }
+
+ @Test
+ fun testEndSurvey_afterCompletingMandatoryQuestions_logsMandatorySurveyResponseEvent() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+ // Submit and navigate to FEEDBACK question
+ submitNpsAnswer(10)
+ stopSurveySession(surveyCompleted = false)
+
+ val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent()
+ EventLogSubject.assertThat(eventLog).hasMandatorySurveyResponseContextThat {
+ hasSurveyDetailsThat {
+ hasSurveyIdThat().isNotEmpty()
+ hasInternalProfileIdThat().isEqualTo("1")
+ }
+ hasUserTypeAnswerThat().isEqualTo(UserTypeAnswer.PARENT)
+ hasMarketFitAnswerThat().isEqualTo(MarketFitAnswer.VERY_DISAPPOINTED)
+ hasNpsScoreAnswerThat().isEqualTo(10)
+ }
+ }
+
+ @Test
+ fun testEndSurvey_afterCompletingAllQuestions_logsMandatorySurveyResponseEvent() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+ submitNpsAnswer(10)
+ submitTextInputAnswer(SurveyQuestionName.PROMOTER_FEEDBACK, TEXT_ANSWER)
+ stopSurveySession(surveyCompleted = true)
+
+ val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent()
+ EventLogSubject.assertThat(eventLog).hasMandatorySurveyResponseContextThat {
+ hasSurveyDetailsThat {
+ hasSurveyIdThat().isNotEmpty()
+ hasInternalProfileIdThat().isEqualTo("1")
+ }
+ hasUserTypeAnswerThat().isEqualTo(UserTypeAnswer.PARENT)
+ hasMarketFitAnswerThat().isEqualTo(MarketFitAnswer.VERY_DISAPPOINTED)
+ hasNpsScoreAnswerThat().isEqualTo(10)
+ }
+ }
+
+ // TODO(#5001): Add tests for Optional responses logging to Firestore
+
+ private fun stopSurveySession(surveyCompleted: Boolean) {
+ val stopProvider = surveyController.stopSurveySession(surveyCompleted)
monitorFactory.waitForNextSuccessfulResult(stopProvider)
}
private fun startSuccessfulSurveySession() {
monitorFactory.waitForNextSuccessfulResult(
- surveyController.startSurveySession(questions)
+ surveyController.startSurveySession(questions, profileId = profileId)
)
}
@@ -433,6 +514,13 @@ class SurveyProgressControllerTest {
@GlobalLogLevel
@Provides
fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE
+
+ @Provides
+ @EnableLearnerStudyAnalytics
+ fun provideLearnerStudyAnalytics(): PlatformParameterValue {
+ // Enable the study by default in tests.
+ return PlatformParameterValue.createDefaultParameter(defaultValue = true)
+ }
}
@Module
diff --git a/model/src/main/proto/BUILD.bazel b/model/src/main/proto/BUILD.bazel
index dcc48bc0202..3993aeee960 100644
--- a/model/src/main/proto/BUILD.bazel
+++ b/model/src/main/proto/BUILD.bazel
@@ -48,6 +48,7 @@ oppia_proto_library(
":exploration_proto",
":languages_proto",
":profile_proto",
+ ":survey_proto",
],
)
diff --git a/model/src/main/proto/arguments.proto b/model/src/main/proto/arguments.proto
index 4d9668acaa8..35b7278d833 100644
--- a/model/src/main/proto/arguments.proto
+++ b/model/src/main/proto/arguments.proto
@@ -310,4 +310,7 @@ message SurveyActivityParams {
// The ID of the topic to which the triggering exploration belongs.
string topic_id = 2;
+
+ // The ID of the triggering exploration.
+ string exploration_id = 3;
}
diff --git a/model/src/main/proto/oppia_logger.proto b/model/src/main/proto/oppia_logger.proto
index 708961a86c9..e2d6cc455ea 100644
--- a/model/src/main/proto/oppia_logger.proto
+++ b/model/src/main/proto/oppia_logger.proto
@@ -4,6 +4,7 @@ package model;
import "languages.proto";
import "profile.proto";
+import "survey.proto";
option java_package = "org.oppia.android.app.model";
option java_multiple_files = true;
@@ -158,6 +159,22 @@ message EventLog {
// Indicates that something went wrong when trying to log a learner analytics even for the
// device corresponding to the specified device ID.
string install_id_for_failed_analytics_log = 33;
+
+ // The event being logged is related to viewing a survey popup dialog.
+ SurveyContext show_survey_popup = 38;
+
+ // The event being logged is related to a survey session being started.
+ SurveyContext begin_survey = 39;
+
+ // The event being logged is related to a survey session being started and ended before the
+ // mandatory questions are completed.
+ AbandonSurveyContext abandon_survey = 40;
+
+ // The event being logged is related to the responses to the mandatory survey questions.
+ MandatorySurveyResponseContext mandatory_response = 41;
+
+ // The event being logged is related to the response to the optional survey question.
+ OptionalSurveyResponseContext optional_response = 42;
}
}
@@ -299,6 +316,58 @@ message EventLog {
OppiaLanguage switch_to_language = 3;
}
+ // Structure of a survey context.
+ message SurveyContext {
+ // The active topic ID when the event is logged.
+ string topic_id = 1;
+
+ // The active exploration ID when the event is logged.
+ string exploration_id = 2;
+ }
+
+ // Represents the event context for when a survey is exited without completing all the mandatory
+ // questions.
+ message AbandonSurveyContext {
+ // Defined attributes that are common among other survey related event log contexts.
+ SurveyResponseContext survey_details = 1;
+
+ // The semantic name of the question at which the survey was abandoned.
+ SurveyQuestionName question_name = 2;
+ }
+
+ // Represents the event context that contains the responses to the mandatory survey questions.
+ message MandatorySurveyResponseContext {
+ // Defined attributes that are common among other survey related event log contexts.
+ SurveyResponseContext survey_details = 1;
+
+ // The semantic name of the selected answer for the user type question.
+ UserTypeAnswer user_type_answer = 2;
+
+ // The semantic name of the selected answer for the market fit question.
+ MarketFitAnswer market_fit_answer = 3;
+
+ // The integer value representing the score selected by the user for the NPS question.
+ int32 nps_score_answer = 4;
+ }
+
+ // Represents the event context that contains the response to the optional survey question.
+ message OptionalSurveyResponseContext {
+ // Defined attributes that are common among other survey related event log contexts.
+ SurveyResponseContext survey_details = 1;
+
+ // The string value representing the free form answer given for the feedback question.
+ string feedback_answer = 2;
+ }
+
+ // Structure of a survey response context.
+ message SurveyResponseContext {
+ // The ID of the survey for which this response event is being logged.
+ string survey_id = 1;
+
+ // The ID of the Oppia profile currently logged in, responding to the survey.
+ string profile_id = 2;
+ }
+
// Supported priority of events for event logging
enum Priority {
// The undefined priority of an event
diff --git a/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt b/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt
index b7c1fd9f618..cbd7163ce19 100644
--- a/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt
+++ b/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt
@@ -15,10 +15,12 @@ import org.oppia.android.app.model.AppLanguageSelection
import org.oppia.android.app.model.AppLanguageSelection.SelectionTypeCase.USE_SYSTEM_LANGUAGE_OR_APP_DEFAULT
import org.oppia.android.app.model.AudioTranslationLanguageSelection
import org.oppia.android.app.model.EventLog
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ABANDON_SURVEY
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ACCESS_HINT_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ACCESS_SOLUTION_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.APP_IN_BACKGROUND_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.APP_IN_FOREGROUND_CONTEXT
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.BEGIN_SURVEY
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.CLOSE_REVISION_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.DELETE_PROFILE_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.END_CARD_CONTEXT
@@ -26,6 +28,7 @@ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.EXIT_EXP
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.FINISH_EXPLORATION_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.HINT_UNLOCKED_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.INSTALL_ID_FOR_FAILED_ANALYTICS_LOG
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.MANDATORY_RESPONSE
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_CONCEPT_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_EXPLORATION_ACTIVITY
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_HOME
@@ -41,13 +44,18 @@ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.PAUSE_VO
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.PLAY_VOICE_OVER_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.REACH_INVESTED_ENGAGEMENT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.RESUME_EXPLORATION_CONTEXT
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SHOW_SURVEY_POPUP
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SOLUTION_UNLOCKED_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.START_CARD_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.START_OVER_EXPLORATION_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SUBMIT_ANSWER_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SWITCH_IN_LESSON_LANGUAGE
+import org.oppia.android.app.model.MarketFitAnswer
import org.oppia.android.app.model.OppiaLanguage
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.UserTypeAnswer
import org.oppia.android.app.model.WrittenTranslationLanguageSelection
+import org.oppia.android.testing.logging.EventLogSubject.Companion.assertThat
// TODO(#4272): Add tests for this class.
@@ -926,6 +934,118 @@ class EventLogSubject private constructor(
return assertThat(actual.context.installIdForFailedAnalyticsLog)
}
+ /**
+ * Verifies that the [EventLog] under test has a context corresponding to
+ * [ABANDON_SURVEY] (per [EventLog.Context.getActivityContextCase]).
+ */
+ fun hasAbandonSurveyContext() {
+ assertThat(actual.context.activityContextCase).isEqualTo(ABANDON_SURVEY)
+ }
+
+ /**
+ * Verifies the [EventLog]'s context per [hasAbandonSurveyContext] and returns a
+ * [AbandonSurveyContextSubject] to test the corresponding context.
+ */
+ fun hasAbandonSurveyContextThat(): AbandonSurveyContextSubject {
+ hasAbandonSurveyContext()
+ return AbandonSurveyContextSubject.assertThat(
+ actual.context.abandonSurvey
+ )
+ }
+
+ /**
+ * Verifies the [EventLog]'s context and executes [block].
+ */
+ fun hasAbandonSurveyContextThat(
+ block: AbandonSurveyContextSubject.() -> Unit
+ ) {
+ hasAbandonSurveyContextThat().block()
+ }
+
+ /**
+ * Verifies that the [EventLog] under test has a context corresponding to
+ * [MANDATORY_RESPONSE] (per [EventLog.Context.getActivityContextCase]).
+ */
+ fun hasMandatorySurveyResponseContext() {
+ assertThat(actual.context.activityContextCase).isEqualTo(MANDATORY_RESPONSE)
+ }
+
+ /**
+ * Verifies the [EventLog]'s context per [hasMandatorySurveyResponseContext] and returns a
+ * [MandatorySurveyResponseContextSubject] to test the corresponding context.
+ */
+ fun hasMandatorySurveyResponseContextThat(): MandatorySurveyResponseContextSubject {
+ hasMandatorySurveyResponseContext()
+ return MandatorySurveyResponseContextSubject.assertThat(
+ actual.context.mandatoryResponse
+ )
+ }
+
+ /**
+ * Verifies the [EventLog]'s context and executes [block].
+ */
+ fun hasMandatorySurveyResponseContextThat(
+ block: MandatorySurveyResponseContextSubject.() -> Unit
+ ) {
+ hasMandatorySurveyResponseContextThat().block()
+ }
+
+ /**
+ * Verifies that the [EventLog] under test has a context corresponding to
+ * [SHOW_SURVEY_POPUP] (per [EventLog.Context.getActivityContextCase]).
+ */
+ fun hasShowSurveyPopupContext() {
+ assertThat(actual.context.activityContextCase).isEqualTo(SHOW_SURVEY_POPUP)
+ }
+
+ /**
+ * Verifies the [EventLog]'s context per [hasShowSurveyPopupContext] and returns a
+ * [SurveyContextSubject] to test the corresponding context.
+ */
+ fun hasShowSurveyPopupContextThat(): SurveyContextSubject {
+ hasShowSurveyPopupContext()
+ return SurveyContextSubject.assertThat(
+ actual.context.showSurveyPopup
+ )
+ }
+
+ /**
+ * Verifies the [EventLog]'s context and executes [block].
+ */
+ fun hasShowSurveyPopupContextThat(
+ block: SurveyContextSubject.() -> Unit
+ ) {
+ hasShowSurveyPopupContextThat().block()
+ }
+
+ /**
+ * Verifies that the [EventLog] under test has a context corresponding to
+ * [BEGIN_SURVEY] (per [EventLog.Context.getActivityContextCase]).
+ */
+ fun hasBeginSurveyContext() {
+ assertThat(actual.context.activityContextCase).isEqualTo(BEGIN_SURVEY)
+ }
+
+ /**
+ * Verifies the [EventLog]'s context per [hasBeginSurveyContext] and returns a
+ * [SurveyContextSubject] to test the corresponding context.
+ */
+ fun hasBeginSurveyContextThat(): SurveyContextSubject {
+ hasBeginSurveyContext()
+ return SurveyContextSubject.assertThat(
+ actual.context.beginSurvey
+ )
+ }
+
+ /**
+ * Verifies the [EventLog]'s context and executes [block].
+ */
+ fun hasBeginSurveyContextThat(
+ block: SurveyContextSubject.() -> Unit
+ ) {
+ hasBeginSurveyContextThat().block()
+ }
+
/**
* Truth subject for verifying properties of [AppLanguageSelection]s.
*
@@ -1634,6 +1754,196 @@ class EventLogSubject private constructor(
}
}
+ /**
+ * Truth subject for verifying properties of [EventLog.MandatorySurveyResponseContext]s.
+ *
+ * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying
+ * [EventLog.MandatorySurveyResponseContext] proto can be verified through inherited methods.
+ *
+ * Call [MandatorySurveyResponseContextSubject.assertThat] to create the subject.
+ */
+ class MandatorySurveyResponseContextSubject private constructor(
+ metadata: FailureMetadata,
+ private val actual: EventLog.MandatorySurveyResponseContext
+ ) : LiteProtoSubject(metadata, actual) {
+ /**
+ * Returns a [SurveyResponseContextSubject] to test
+ * [EventLog.AbandonSurveyContext.getSurveyDetails].
+ *
+ * This method never fails since the underlying property defaults to empty object if it's not
+ * defined in the context.
+ */
+ fun hasSurveyDetailsThat(): SurveyResponseContextSubject =
+ SurveyResponseContextSubject.assertThat(actual.surveyDetails)
+
+ /** Executes [block] in the context returned by [hasSurveyDetailsThat]. */
+ fun hasSurveyDetailsThat(block: SurveyResponseContextSubject.() -> Unit) {
+ hasSurveyDetailsThat().block()
+ }
+
+ /**
+ * Returns a [ComparableSubject] to test
+ * [EventLog.MandatorySurveyResponseContext.getUserTypeAnswer].
+ *
+ * This method never fails since the underlying property defaults to empty object if it's not
+ * defined in the context.
+ */
+ fun hasUserTypeAnswerThat(): ComparableSubject =
+ assertThat(actual.userTypeAnswer)
+
+ /**
+ * Returns a [ComparableSubject] to test
+ * [EventLog.MandatorySurveyResponseContext.getMarketFitAnswer].
+ *
+ * This method never fails since the underlying property defaults to empty object if it's not
+ * defined in the context.
+ */
+ fun hasMarketFitAnswerThat(): ComparableSubject =
+ assertThat(actual.marketFitAnswer)
+
+ /**
+ * Returns a [ComparableSubject] to test
+ * [EventLog.MandatorySurveyResponseContext.getUserTypeAnswer].
+ *
+ * This method never fails since the underlying property defaults to empty object if it's not
+ * defined in the context.
+ */
+ fun hasNpsScoreAnswerThat(): IntegerSubject =
+ assertThat(actual.npsScoreAnswer)
+
+ companion object {
+ /**
+ * Returns a new [AbandonSurveyContextSubject] to verify aspects of the specified
+ * [EventLog.AbandonSurveyContext] value.
+ */
+ fun assertThat(actual: EventLog.MandatorySurveyResponseContext):
+ MandatorySurveyResponseContextSubject =
+ assertAbout(::MandatorySurveyResponseContextSubject).that(actual)
+ }
+ }
+
+ /**
+ * Truth subject for verifying properties of [EventLog.AbandonSurveyContext]s.
+ *
+ * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying
+ * [EventLog.AbandonSurveyContext] proto can be verified through inherited methods.
+ *
+ * Call [AbandonSurveyContextSubject.assertThat] to create the subject.
+ */
+ class AbandonSurveyContextSubject private constructor(
+ metadata: FailureMetadata,
+ private val actual: EventLog.AbandonSurveyContext
+ ) : LiteProtoSubject(metadata, actual) {
+ /**
+ * Returns a [SurveyResponseContextSubject] to test
+ * [EventLog.AbandonSurveyContext.getSurveyDetails].
+ *
+ * This method never fails since the underlying property defaults to empty object if it's not
+ * defined in the context.
+ */
+ fun hasSurveyDetailsThat(): SurveyResponseContextSubject =
+ SurveyResponseContextSubject.assertThat(actual.surveyDetails)
+
+ /** Executes [block] in the context returned by [hasSurveyDetailsThat]. */
+ fun hasSurveyDetailsThat(block: SurveyResponseContextSubject.() -> Unit) {
+ hasSurveyDetailsThat().block()
+ }
+
+ /**
+ * Returns a [ComparableSubject] to test [EventLog.AbandonSurveyContext.getQuestionName].
+ *
+ * This method never fails since the underlying property defaults to empty object if it's not
+ * defined in the context.
+ */
+ fun hasQuestionNameThat(): ComparableSubject =
+ assertThat(actual.questionName)
+
+ companion object {
+ /**
+ * Returns a new [AbandonSurveyContextSubject] to verify aspects of the specified
+ * [EventLog.AbandonSurveyContext] value.
+ */
+ fun assertThat(actual: EventLog.AbandonSurveyContext): AbandonSurveyContextSubject =
+ assertAbout(::AbandonSurveyContextSubject).that(actual)
+ }
+ }
+
+ /**
+ * Truth subject for verifying properties of [EventLog.SurveyResponseContext]s.
+ *
+ * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying
+ * [EventLog.SurveyResponseContext] proto can be verified through inherited methods.
+ *
+ * Call [SurveyResponseContextSubject.assertThat] to create the subject.
+ */
+ class SurveyResponseContextSubject private constructor(
+ metadata: FailureMetadata,
+ private val actual: EventLog.SurveyResponseContext
+ ) : LiteProtoSubject(metadata, actual) {
+ /**
+ * Returns a [StringSubject] to test [EventLog.SurveyResponseContext.getSurveyId].
+ *
+ * This method never fails since the underlying property defaults to empty string if it's not
+ * defined in the context.
+ */
+ fun hasSurveyIdThat(): StringSubject = assertThat(actual.surveyId)
+
+ /**
+ * Returns a [StringSubject] to test [EventLog.SurveyResponseContext.getSurveyId].
+ *
+ * This method never fails since the underlying property defaults to empty string if it's not
+ * defined in the context.
+ */
+ fun hasInternalProfileIdThat(): StringSubject = assertThat(actual.profileId)
+
+ companion object {
+ /**
+ * Returns a new [SurveyResponseContextSubject] to verify aspects of the specified
+ * [EventLog.SurveyResponseContext] value.
+ */
+ fun assertThat(actual: EventLog.SurveyResponseContext): SurveyResponseContextSubject =
+ assertAbout(::SurveyResponseContextSubject).that(actual)
+ }
+ }
+
+ /**
+ * Truth subject for verifying properties of [EventLog.SurveyContext]s.
+ *
+ * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying
+ * [EventLog.SurveyContext] proto can be verified through inherited methods.
+ *
+ * Call [SurveyContextSubject.assertThat] to create the subject.
+ */
+ class SurveyContextSubject private constructor(
+ metadata: FailureMetadata,
+ private val actual: EventLog.SurveyContext
+ ) : LiteProtoSubject(metadata, actual) {
+ /**
+ * Returns a [StringSubject] to test [EventLog.SurveyContext.getExplorationId].
+ *
+ * This method never fails since the underlying property defaults to empty string if it's not
+ * defined in the context.
+ */
+ fun hasExplorationIdThat(): StringSubject = assertThat(actual.explorationId)
+
+ /**
+ * Returns a [StringSubject] to test [EventLog.SurveyContext.getTopicId].
+ *
+ * This method never fails since the underlying property defaults to empty string if it's not
+ * defined in the context.
+ */
+ fun hasTopicIdThat(): StringSubject = assertThat(actual.topicId)
+
+ companion object {
+ /**
+ * Returns a new [SurveyContextSubject] to verify aspects of the specified
+ * [EventLog.SurveyContext] value.
+ */
+ fun assertThat(actual: EventLog.SurveyContext): SurveyContextSubject =
+ assertAbout(::SurveyContextSubject).that(actual)
+ }
+ }
+
companion object {
/** Returns a new [EventLogSubject] to verify aspects of the specified [EventLog] value. */
fun assertThat(actual: EventLog): EventLogSubject = assertAbout(::EventLogSubject).that(actual)
diff --git a/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt b/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt
index cc6582638e1..100f1c86c4c 100644
--- a/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt
+++ b/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt
@@ -6,11 +6,13 @@ import android.os.Bundle
import org.oppia.android.app.model.AppLanguageSelection
import org.oppia.android.app.model.AudioTranslationLanguageSelection
import org.oppia.android.app.model.EventLog
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ABANDON_SURVEY
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ACCESS_HINT_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ACCESS_SOLUTION_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ACTIVITYCONTEXT_NOT_SET
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.APP_IN_BACKGROUND_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.APP_IN_FOREGROUND_CONTEXT
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.BEGIN_SURVEY
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.CLOSE_REVISION_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.DELETE_PROFILE_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.END_CARD_CONTEXT
@@ -18,6 +20,7 @@ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.EXIT_EXP
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.FINISH_EXPLORATION_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.HINT_UNLOCKED_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.INSTALL_ID_FOR_FAILED_ANALYTICS_LOG
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.MANDATORY_RESPONSE
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_CONCEPT_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_EXPLORATION_ACTIVITY
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_HOME
@@ -29,10 +32,12 @@ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_QUE
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_TAB
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_STORY_ACTIVITY
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPTIONAL_RESPONSE
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.PAUSE_VOICE_OVER_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.PLAY_VOICE_OVER_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.REACH_INVESTED_ENGAGEMENT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.RESUME_EXPLORATION_CONTEXT
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SHOW_SURVEY_POPUP
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SOLUTION_UNLOCKED_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.START_CARD_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.START_OVER_EXPLORATION_CONTEXT
@@ -53,17 +58,21 @@ import org.oppia.android.app.model.ScreenName
import org.oppia.android.app.model.WrittenTranslationLanguageSelection
import org.oppia.android.app.utility.getVersionCode
import org.oppia.android.app.utility.getVersionName
+import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.AbandonSurveyContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.CardContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.ConceptCardContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.EmptyContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.ExplorationContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.HintContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.LearnerDetailsContext
+import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.MandatorySurveyResponseContext
+import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.OptionalSurveyResponseContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.QuestionContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.RevisionCardContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.SensitiveStringContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.StoryContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.SubmitAnswerContext
+import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.SurveyContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.SwitchInLessonLanguageContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.TopicContext
import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.VoiceoverActionContext
@@ -78,15 +87,19 @@ import org.oppia.android.util.platformparameter.PlatformParameterValue
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
import javax.inject.Singleton
+import org.oppia.android.app.model.EventLog.AbandonSurveyContext as AbandonSurveyEventContext
import org.oppia.android.app.model.EventLog.CardContext as CardEventContext
import org.oppia.android.app.model.EventLog.ConceptCardContext as ConceptCardEventContext
import org.oppia.android.app.model.EventLog.ExplorationContext as ExplorationEventContext
import org.oppia.android.app.model.EventLog.HintContext as HintEventContext
import org.oppia.android.app.model.EventLog.LearnerDetailsContext as LearnerDetailsEventContext
+import org.oppia.android.app.model.EventLog.MandatorySurveyResponseContext as MandatorySurveyResponseEventContext
+import org.oppia.android.app.model.EventLog.OptionalSurveyResponseContext as OptionalSurveyResponseEventContext
import org.oppia.android.app.model.EventLog.QuestionContext as QuestionEventContext
import org.oppia.android.app.model.EventLog.RevisionCardContext as RevisionCardEventContext
import org.oppia.android.app.model.EventLog.StoryContext as StoryEventContext
import org.oppia.android.app.model.EventLog.SubmitAnswerContext as SubmitAnswerEventContext
+import org.oppia.android.app.model.EventLog.SurveyContext as SurveyEventContext
import org.oppia.android.app.model.EventLog.TopicContext as TopicEventContext
import org.oppia.android.app.model.EventLog.VoiceoverActionContext as VoiceoverActionEventContext
import org.oppia.android.app.model.OppiaMetricLog.ApkSizeMetric as ApkSizePerformanceLoggableMetric
@@ -205,6 +218,11 @@ class EventBundleCreator @Inject constructor(
REACH_INVESTED_ENGAGEMENT -> ExplorationContext(activityName, reachInvestedEngagement)
SWITCH_IN_LESSON_LANGUAGE ->
SwitchInLessonLanguageContext(activityName, switchInLessonLanguage)
+ SHOW_SURVEY_POPUP -> SurveyContext(activityName, showSurveyPopup)
+ BEGIN_SURVEY -> SurveyContext(activityName, beginSurvey)
+ ABANDON_SURVEY -> AbandonSurveyContext(activityName, abandonSurvey)
+ MANDATORY_RESPONSE -> MandatorySurveyResponseContext(activityName, mandatoryResponse)
+ OPTIONAL_RESPONSE -> OptionalSurveyResponseContext(activityName, optionalResponse)
INSTALL_ID_FOR_FAILED_ANALYTICS_LOG ->
SensitiveStringContext(activityName, installIdForFailedAnalyticsLog, "install_id")
ACTIVITYCONTEXT_NOT_SET, null -> EmptyContext(activityName) // No context to create here.
@@ -490,6 +508,55 @@ class EventBundleCreator @Inject constructor(
class EmptyContext(activityName: String) : EventActivityContext(activityName, Unit) {
override fun Unit.storeValue(store: PropertyStore) {}
}
+
+ /** The [EventActivityContext] corresponding to [SurveyEventContext]s. */
+ class SurveyContext(
+ activityName: String,
+ value: SurveyEventContext
+ ) : EventActivityContext(activityName, value) {
+ override fun SurveyEventContext.storeValue(store: PropertyStore) {
+ store.putNonSensitiveValue("topic_id", topicId)
+ store.putNonSensitiveValue("exploration_id", explorationId)
+ }
+ }
+
+ /** The [EventActivityContext] corresponding to [MandatorySurveyResponseEventContext]s. */
+ class MandatorySurveyResponseContext(
+ activityName: String,
+ value: MandatorySurveyResponseEventContext
+ ) : EventActivityContext(activityName, value) {
+ override fun MandatorySurveyResponseEventContext.storeValue(store: PropertyStore) {
+ store.putNonSensitiveValue("survey_id", surveyDetails.surveyId)
+ store.putSensitiveValue("profile_id", surveyDetails.profileId)
+ store.putNonSensitiveValue("user_type_answer", userTypeAnswer)
+ store.putNonSensitiveValue("market_fit_answer", marketFitAnswer)
+ store.putNonSensitiveValue("nps_score_answer", npsScoreAnswer)
+ }
+ }
+
+ /** The [EventActivityContext] corresponding to [OptionalSurveyResponseEventContext]s. */
+ class OptionalSurveyResponseContext(
+ activityName: String,
+ value: OptionalSurveyResponseEventContext
+ ) : EventActivityContext(activityName, value) {
+ override fun OptionalSurveyResponseEventContext.storeValue(store: PropertyStore) {
+ store.putNonSensitiveValue("survey_id", surveyDetails.surveyId)
+ store.putSensitiveValue("profile_id", surveyDetails.profileId)
+ store.putSensitiveValue("feedback_answer", feedbackAnswer)
+ }
+ }
+
+ /** The [EventActivityContext] corresponding to [AbandonSurveyEventContext]s. */
+ class AbandonSurveyContext(
+ activityName: String,
+ value: AbandonSurveyEventContext
+ ) : EventActivityContext(activityName, value) {
+ override fun AbandonSurveyEventContext.storeValue(store: PropertyStore) {
+ store.putNonSensitiveValue("survey_id", surveyDetails.surveyId)
+ store.putSensitiveValue("profile_id", surveyDetails.profileId)
+ store.putNonSensitiveValue("question_name", questionName)
+ }
+ }
}
/** Represents an [OppiaMetricLog] loggable metric (denoted by [LoggableMetricTypeCase]). */
diff --git a/utility/src/main/java/org/oppia/android/util/logging/KenyaAlphaEventTypeToHumanReadableNameConverterImpl.kt b/utility/src/main/java/org/oppia/android/util/logging/KenyaAlphaEventTypeToHumanReadableNameConverterImpl.kt
index 056621c0f4c..4f11a6e5f8d 100644
--- a/utility/src/main/java/org/oppia/android/util/logging/KenyaAlphaEventTypeToHumanReadableNameConverterImpl.kt
+++ b/utility/src/main/java/org/oppia/android/util/logging/KenyaAlphaEventTypeToHumanReadableNameConverterImpl.kt
@@ -45,6 +45,11 @@ class KenyaAlphaEventTypeToHumanReadableNameConverterImpl @Inject constructor()
ActivityContextCase.OPEN_PROFILE_CHOOSER -> "open_profile_chooser"
ActivityContextCase.REACH_INVESTED_ENGAGEMENT -> "reached_invested_engagement"
ActivityContextCase.SWITCH_IN_LESSON_LANGUAGE -> "switch_in_lesson_language"
+ ActivityContextCase.SHOW_SURVEY_POPUP -> "show_survey_popup"
+ ActivityContextCase.BEGIN_SURVEY -> "begin_survey"
+ ActivityContextCase.ABANDON_SURVEY -> "abandon_survey"
+ ActivityContextCase.MANDATORY_RESPONSE -> "mandatory_response"
+ ActivityContextCase.OPTIONAL_RESPONSE -> "optional_response"
ActivityContextCase.INSTALL_ID_FOR_FAILED_ANALYTICS_LOG -> "failed_analytics_log"
ActivityContextCase.ACTIVITYCONTEXT_NOT_SET -> "unknown_activity_context"
}
diff --git a/utility/src/main/java/org/oppia/android/util/logging/StandardEventTypeToHumanReadableNameConverterImpl.kt b/utility/src/main/java/org/oppia/android/util/logging/StandardEventTypeToHumanReadableNameConverterImpl.kt
index cc687478320..4c71ee5446e 100644
--- a/utility/src/main/java/org/oppia/android/util/logging/StandardEventTypeToHumanReadableNameConverterImpl.kt
+++ b/utility/src/main/java/org/oppia/android/util/logging/StandardEventTypeToHumanReadableNameConverterImpl.kt
@@ -55,6 +55,11 @@ class StandardEventTypeToHumanReadableNameConverterImpl @Inject constructor() :
ActivityContextCase.OPEN_PROFILE_CHOOSER -> "open_profile_chooser_screen"
ActivityContextCase.REACH_INVESTED_ENGAGEMENT -> "reach_invested_engagement"
ActivityContextCase.SWITCH_IN_LESSON_LANGUAGE -> "click_switch_language_in_lesson"
+ ActivityContextCase.SHOW_SURVEY_POPUP -> "show_survey_popup"
+ ActivityContextCase.BEGIN_SURVEY -> "begin_survey"
+ ActivityContextCase.ABANDON_SURVEY -> "abandon_survey"
+ ActivityContextCase.MANDATORY_RESPONSE -> "mandatory_response"
+ ActivityContextCase.OPTIONAL_RESPONSE -> "optional_response"
ActivityContextCase.INSTALL_ID_FOR_FAILED_ANALYTICS_LOG,
ActivityContextCase.ACTIVITYCONTEXT_NOT_SET -> "ERROR_internal_logging_failure"
}
From 96923060bc675d26f86a3e8a32a82d28f1f16600 Mon Sep 17 00:00:00 2001
From: Kenneth Murerwa
Date: Wed, 12 Jul 2023 18:57:51 +0300
Subject: [PATCH 10/33] Fix part of #5025: App and OS Deprecation Milestone 2 -
Add protos and the DeprecationController (#4999)
## Explanation
Fix part of #5025: When this PR is merged, it will;
- Add a new `deprecation.proto` file that will allow the storage of
deprecation responses as well as provide the various deprecation types.
- Add the `OPTIONAL_UPDATE_AVAILABLE` and the `OS_IS_DEPRECATED` startup
modes on the `onboarding.proto` file for the two new startup modes being
introduced.
- Create a `DeprecationController` class and add tests for the class in
the `DeprecationControllerTest`.
- Modify BUILD.bazel files to provide the new proto files and the
deprecation controller.
- Add `DeprecationControllerTest` to the `OppiaParameterizedTestRunner`
file exemptions on the `file_content_validation_checks.textproto`.
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
---------
Co-authored-by: Kenneth Murerwa
Co-authored-by: Adhiambo Peres <59600948+adhiamboperes@users.noreply.github.com>
---
domain/BUILD.bazel | 1 +
.../android/domain/onboarding/BUILD.bazel | 23 ++
.../onboarding/DeprecationController.kt | 117 ++++++++
.../android/domain/onboarding/BUILD.bazel | 31 ++
.../onboarding/DeprecationControllerTest.kt | 279 ++++++++++++++++++
model/src/main/proto/BUILD.bazel | 12 +
model/src/main/proto/deprecation.proto | 47 +++
model/src/main/proto/onboarding.proto | 9 +
.../file_content_validation_checks.textproto | 1 +
9 files changed, 520 insertions(+)
create mode 100644 domain/src/main/java/org/oppia/android/domain/onboarding/DeprecationController.kt
create mode 100644 domain/src/test/java/org/oppia/android/domain/onboarding/DeprecationControllerTest.kt
create mode 100644 model/src/main/proto/deprecation.proto
diff --git a/domain/BUILD.bazel b/domain/BUILD.bazel
index 1590004cf89..79bcd289f08 100755
--- a/domain/BUILD.bazel
+++ b/domain/BUILD.bazel
@@ -199,6 +199,7 @@ TEST_DEPS = [
"//domain/src/main/java/org/oppia/android/domain/classify/rules/textinput:text_input_rule_module",
"//domain/src/main/java/org/oppia/android/domain/feedbackreporting:prod_module",
"//domain/src/main/java/org/oppia/android/domain/feedbackreporting:report_schema_version",
+ "//domain/src/main/java/org/oppia/android/domain/onboarding:deprecation_controller",
"//domain/src/main/java/org/oppia/android/domain/onboarding:retriever_prod_module",
"//domain/src/main/java/org/oppia/android/domain/onboarding:state_controller",
"//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module",
diff --git a/domain/src/main/java/org/oppia/android/domain/onboarding/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/onboarding/BUILD.bazel
index f3ba2025c05..ae8e6e2d0ef 100644
--- a/domain/src/main/java/org/oppia/android/domain/onboarding/BUILD.bazel
+++ b/domain/src/main/java/org/oppia/android/domain/onboarding/BUILD.bazel
@@ -9,14 +9,17 @@ kt_android_library(
name = "state_controller",
srcs = [
"AppStartupStateController.kt",
+ "DeprecationController.kt",
],
visibility = ["//:oppia_api_visibility"],
deps = [
":exploration_meta_data_retriever",
"//data/src/main/java/org/oppia/android/data/persistence:cache_store",
"//domain/src/main/java/org/oppia/android/domain/oppialogger:oppia_logger",
+ "//model/src/main/proto:deprecation_java_proto_lite",
"//model/src/main/proto:onboarding_java_proto_lite",
"//third_party:javax_inject_javax_inject",
+ "//utility",
"//utility/src/main/java/org/oppia/android/util/data:data_provider",
"//utility/src/main/java/org/oppia/android/util/data:data_providers",
"//utility/src/main/java/org/oppia/android/util/extensions:bundle_extensions",
@@ -55,4 +58,24 @@ kt_android_library(
],
)
+kt_android_library(
+ name = "deprecation_controller",
+ srcs = [
+ "DeprecationController.kt",
+ ],
+ visibility = ["//:oppia_api_visibility"],
+ deps = [
+ ":exploration_meta_data_retriever",
+ "//data/src/main/java/org/oppia/android/data/persistence:cache_store",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger:oppia_logger",
+ "//model/src/main/proto:deprecation_java_proto_lite",
+ "//model/src/main/proto:onboarding_java_proto_lite",
+ "//third_party:javax_inject_javax_inject",
+ "//utility",
+ "//utility/src/main/java/org/oppia/android/util/data:data_provider",
+ "//utility/src/main/java/org/oppia/android/util/data:data_providers",
+ "//utility/src/main/java/org/oppia/android/util/extensions:bundle_extensions",
+ ],
+)
+
dagger_rules()
diff --git a/domain/src/main/java/org/oppia/android/domain/onboarding/DeprecationController.kt b/domain/src/main/java/org/oppia/android/domain/onboarding/DeprecationController.kt
new file mode 100644
index 00000000000..d798fe83b62
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/onboarding/DeprecationController.kt
@@ -0,0 +1,117 @@
+package org.oppia.android.domain.onboarding
+
+import kotlinx.coroutines.Deferred
+import org.oppia.android.app.model.DeprecationNoticeType
+import org.oppia.android.app.model.DeprecationResponse
+import org.oppia.android.app.model.DeprecationResponseDatabase
+import org.oppia.android.data.persistence.PersistentCacheStore
+import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProvider
+import org.oppia.android.util.data.DataProviders
+import org.oppia.android.util.data.DataProviders.Companion.transform
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val GET_DEPRECATION_RESPONSE_PROVIDER_ID = "get_deprecation_response_provider_id"
+private const val ADD_DEPRECATION_RESPONSE_PROVIDER_ID = "add_deprecation_response_provider_id"
+
+/**
+ * Controller for persisting and retrieving the user's deprecation responses. This will be used to
+ * handle deprecations once the user opens the app.
+ */
+@Singleton
+class DeprecationController @Inject constructor(
+ cacheStoreFactory: PersistentCacheStore.Factory,
+ private val oppiaLogger: OppiaLogger,
+ private val dataProviders: DataProviders
+) {
+ /** Create an instance of [PersistentCacheStore] that contains a [DeprecationResponseDatabase]. */
+ private val deprecationStore by lazy {
+ cacheStoreFactory.create(
+ "deprecation_store",
+ DeprecationResponseDatabase.getDefaultInstance()
+ )
+ }
+
+ /** Enum states for the possible outcomes of a deprecation action. */
+ private enum class DeprecationResponseActionStatus {
+ /** Indicates that the deprecation response read/write operation succeeded. */
+ SUCCESS
+ }
+
+ init {
+ // Prime the cache ahead of time so that the deprecation response can be retrieved
+ // synchronously.
+ deprecationStore.primeInMemoryAndDiskCacheAsync(
+ updateMode = PersistentCacheStore.UpdateMode.UPDATE_ALWAYS,
+ publishMode = PersistentCacheStore.PublishMode.PUBLISH_TO_IN_MEMORY_CACHE
+ ).invokeOnCompletion { primeFailure ->
+ primeFailure?.let {
+ oppiaLogger.e(
+ "DeprecationController",
+ "Failed to prime cache ahead of data retrieval for DeprecationController.",
+ primeFailure
+ )
+ }
+ }
+ }
+
+ private val deprecationDataProvider by lazy { fetchDeprecationProvider() }
+
+ private fun fetchDeprecationProvider(): DataProvider {
+ return deprecationStore.transform(
+ GET_DEPRECATION_RESPONSE_PROVIDER_ID
+ ) { deprecationResponsesDatabase ->
+ DeprecationResponseDatabase.newBuilder().apply {
+ appDeprecationResponse = deprecationResponsesDatabase.appDeprecationResponse
+ osDeprecationResponse = deprecationResponsesDatabase.osDeprecationResponse
+ }.build()
+ }
+ }
+
+ /**
+ * Returns a [DataProvider] containing the the [DeprecationResponseDatabase], which in turn
+ * affects what initial app flow the user is directed to.
+ */
+ fun getDeprecationDatabase(): DataProvider = deprecationDataProvider
+
+ /**
+ * Stores a new [DeprecationResponse] to the cache.
+ *
+ * @param deprecationResponse the deprecation response to be stored
+ * @return [AsyncResult] of the deprecation action
+ */
+ fun saveDeprecationResponse(deprecationResponse: DeprecationResponse): DataProvider {
+ val deferred = deprecationStore.storeDataWithCustomChannelAsync(
+ updateInMemoryCache = true
+ ) { deprecationResponseDb ->
+ val deprecationBuilder = deprecationResponseDb.toBuilder().apply {
+ if (deprecationResponse.deprecationNoticeType == DeprecationNoticeType.APP_DEPRECATION)
+ appDeprecationResponse = deprecationResponse
+ else
+ osDeprecationResponse = deprecationResponse
+ }
+ .build()
+ Pair(deprecationBuilder, DeprecationResponseActionStatus.SUCCESS)
+ }
+
+ return dataProviders.createInMemoryDataProviderAsync(ADD_DEPRECATION_RESPONSE_PROVIDER_ID) {
+ return@createInMemoryDataProviderAsync getDeferredResult(deferred)
+ }
+ }
+
+ /**
+ * Retrieves the [DeprecationResponse] from the cache.
+ *
+ * @param deferred a deferred instance of the [DeprecationResponseActionStatus]
+ * @return [AsyncResult]
+ */
+ private suspend fun getDeferredResult(
+ deferred: Deferred
+ ): AsyncResult {
+ return when (deferred.await()) {
+ DeprecationResponseActionStatus.SUCCESS -> AsyncResult.Success(null)
+ }
+ }
+}
diff --git a/domain/src/test/java/org/oppia/android/domain/onboarding/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/onboarding/BUILD.bazel
index 85124495fd0..7014081371a 100644
--- a/domain/src/test/java/org/oppia/android/domain/onboarding/BUILD.bazel
+++ b/domain/src/test/java/org/oppia/android/domain/onboarding/BUILD.bazel
@@ -36,4 +36,35 @@ oppia_android_test(
],
)
+oppia_android_test(
+ name = "DeprecationControllerTest",
+ srcs = ["DeprecationControllerTest.kt"],
+ custom_package = "org.oppia.android.domain.onboarding",
+ test_class = "org.oppia.android.domain.onboarding.DeprecationControllerTest",
+ test_manifest = "//domain:test_manifest",
+ deps = [
+ ":dagger",
+ "//domain",
+ "//domain/src/main/java/org/oppia/android/domain/onboarding:deprecation_controller",
+ "//domain/src/main/java/org/oppia/android/domain/onboarding:retriever_prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module",
+ "//testing",
+ "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor",
+ "//testing/src/main/java/org/oppia/android/testing/junit:oppia_parameterized_test_runner",
+ "//testing/src/main/java/org/oppia/android/testing/junit:parameterized_robolectric_test_runner",
+ "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/threading:test_module",
+ "//third_party:com_google_truth_truth",
+ "//third_party:junit_junit",
+ "//third_party:org_mockito_mockito-core",
+ "//third_party:org_robolectric_robolectric",
+ "//third_party:robolectric_android-all",
+ "//utility/src/main/java/org/oppia/android/util/locale:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/logging:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/networking:debug_module",
+ "//utility/src/main/java/org/oppia/android/util/system:prod_module",
+ ],
+)
+
dagger_rules()
diff --git a/domain/src/test/java/org/oppia/android/domain/onboarding/DeprecationControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/onboarding/DeprecationControllerTest.kt
new file mode 100644
index 00000000000..3ef5714961d
--- /dev/null
+++ b/domain/src/test/java/org/oppia/android/domain/onboarding/DeprecationControllerTest.kt
@@ -0,0 +1,279 @@
+package org.oppia.android.domain.onboarding
+
+import android.app.Application
+import android.content.Context
+import android.os.Bundle
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.core.content.pm.ApplicationInfoBuilder
+import androidx.test.core.content.pm.PackageInfoBuilder
+import com.google.common.truth.Truth.assertThat
+import dagger.BindsInstance
+import dagger.Component
+import dagger.Module
+import dagger.Provides
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.app.model.BuildFlavor
+import org.oppia.android.app.model.DeprecationNoticeType
+import org.oppia.android.app.model.DeprecationResponse
+import org.oppia.android.app.model.DeprecationResponseDatabase
+import org.oppia.android.data.persistence.PersistentCacheStore
+import org.oppia.android.domain.oppialogger.LogStorageModule
+import org.oppia.android.domain.oppialogger.LoggingIdentifierModule
+import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
+import org.oppia.android.domain.platformparameter.PlatformParameterModule
+import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.data.DataProviderTestMonitor
+import org.oppia.android.testing.junit.OppiaParameterizedTestRunner
+import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.SelectRunnerPlatform
+import org.oppia.android.testing.junit.ParameterizedRobolectricTestRunner
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.util.data.DataProvidersInjector
+import org.oppia.android.util.data.DataProvidersInjectorProvider
+import org.oppia.android.util.locale.LocaleProdModule
+import org.oppia.android.util.logging.EnableConsoleLog
+import org.oppia.android.util.logging.EnableFileLog
+import org.oppia.android.util.logging.GlobalLogLevel
+import org.oppia.android.util.logging.LogLevel
+import org.oppia.android.util.logging.SyncStatusModule
+import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.oppia.android.util.system.OppiaClockModule
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [DeprecationController]. */
+// FunctionName: test names are conventionally named with underscores.
+@Suppress("FunctionName")
+@RunWith(OppiaParameterizedTestRunner::class)
+@SelectRunnerPlatform(ParameterizedRobolectricTestRunner::class)
+@Config(application = DeprecationControllerTest.TestApplication::class)
+class DeprecationControllerTest {
+ @Inject lateinit var context: Context
+ @Inject lateinit var deprecationController: DeprecationController
+ @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+ @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory
+
+ @Test
+ fun testController_providesInitialState_indicatesNoUpdatesReceivedFromGatingConsole() {
+ val defaultDeprecationResponseDatabase = DeprecationResponseDatabase
+ .getDefaultInstance()
+
+ setUpDefaultTestApplicationComponent()
+
+ val deprecationDataProvider = deprecationController
+ .getDeprecationDatabase()
+
+ val deprecationResponseDatabase = monitorFactory
+ .waitForNextSuccessfulResult(deprecationDataProvider)
+
+ assertThat(deprecationResponseDatabase.osDeprecationResponse)
+ .isEqualTo(defaultDeprecationResponseDatabase.osDeprecationResponse)
+
+ assertThat(deprecationResponseDatabase.appDeprecationResponse)
+ .isEqualTo(defaultDeprecationResponseDatabase.appDeprecationResponse)
+ }
+
+ @Test
+ fun testController_observedAfterSettingAppDeprecation_providesUpdatedDeprecationResponse() {
+ executeInPreviousAppInstance { testComponent ->
+ val appDeprecationResponse = DeprecationResponse.newBuilder().apply {
+ deprecatedVersion = 5
+ deprecationNoticeType = DeprecationNoticeType.APP_DEPRECATION
+ }.build()
+
+ testComponent.getDeprecationController().saveDeprecationResponse(appDeprecationResponse)
+ testComponent.getTestCoroutineDispatchers().runCurrent()
+ }
+
+ // Create the application after previous arrangement to simulate a re-creation.
+ setUpDefaultTestApplicationComponent()
+
+ val deprecationDataProvider = deprecationController
+ .getDeprecationDatabase()
+
+ val deprecationResponseDatabase = monitorFactory
+ .waitForNextSuccessfulResult(deprecationDataProvider)
+
+ assertThat(deprecationResponseDatabase.appDeprecationResponse)
+ .isEqualTo(
+ DeprecationResponse.newBuilder().apply {
+ deprecatedVersion = 5
+ deprecationNoticeType = DeprecationNoticeType.APP_DEPRECATION
+ }.build()
+ )
+ }
+
+ @Test
+ fun testController_observedAfterSettingOsDeprecation_providesUpdatedDeprecationResponse() {
+ executeInPreviousAppInstance { testComponent ->
+ val osDeprecationResponse = DeprecationResponse.newBuilder().apply {
+ deprecatedVersion = 5
+ deprecationNoticeType = DeprecationNoticeType.OS_DEPRECATION
+ }.build()
+
+ testComponent.getDeprecationController().saveDeprecationResponse(osDeprecationResponse)
+ testComponent.getTestCoroutineDispatchers().runCurrent()
+ }
+
+ // Create the application after previous arrangement to simulate a re-creation.
+ setUpDefaultTestApplicationComponent()
+
+ val deprecationDataProvider = deprecationController
+ .getDeprecationDatabase()
+
+ val deprecationResponseDatabase = monitorFactory
+ .waitForNextSuccessfulResult(deprecationDataProvider)
+
+ assertThat(deprecationResponseDatabase.osDeprecationResponse)
+ .isEqualTo(
+ DeprecationResponse.newBuilder().apply {
+ deprecatedVersion = 5
+ deprecationNoticeType = DeprecationNoticeType.OS_DEPRECATION
+ }.build()
+ )
+ }
+
+ private fun setUpTestApplicationComponent() {
+ ApplicationProvider.getApplicationContext().inject(this)
+ }
+
+ private fun setUpOppiaApplication(expirationEnabled: Boolean, expDate: String) {
+ setUpOppiaApplicationForContext(context, expirationEnabled, expDate)
+ }
+
+ /**
+ * Creates a separate test application component and executes the specified block. This should be
+ * called before [setUpTestApplicationComponent] to avoid undefined behavior in production code.
+ * This can be used to simulate arranging state in a "prior" run of the app.
+ *
+ * Note that only dependencies fetched from the specified [TestApplicationComponent] should be
+ * used, not any class-level injected dependencies.
+ */
+ private fun executeInPreviousAppInstance(block: (TestApplicationComponent) -> Unit) {
+ val testApplication = TestApplication()
+ // The true application is hooked as a base context. This is to make sure the new application
+ // can behave like a real Android application class (per Robolectric) without having a shared
+ // Dagger dependency graph with the application under test.
+ testApplication.attachBaseContext(ApplicationProvider.getApplicationContext())
+ block(
+ DaggerDeprecationControllerTest_TestApplicationComponent.builder()
+ .setApplication(testApplication)
+ .build()
+ )
+ }
+
+ private fun setUpOppiaApplicationForContext(
+ context: Context,
+ expirationEnabled: Boolean,
+ expDate: String
+ ) {
+ val packageManager = Shadows.shadowOf(context.packageManager)
+ val applicationInfo =
+ ApplicationInfoBuilder.newBuilder()
+ .setPackageName(context.packageName)
+ .setName("Oppia")
+ .build()
+ applicationInfo.metaData = Bundle()
+ applicationInfo.metaData.putBoolean("automatic_app_expiration_enabled", expirationEnabled)
+ applicationInfo.metaData.putString("expiration_date", expDate)
+ val packageInfo =
+ PackageInfoBuilder.newBuilder()
+ .setPackageName(context.packageName)
+ .setApplicationInfo(applicationInfo)
+ .build()
+ packageManager.installPackage(packageInfo)
+ }
+
+ private fun setUpDefaultTestApplicationComponent() {
+ setUpTestApplicationComponent()
+
+ // By default, set up the application to never expire.
+ setUpOppiaApplication(expirationEnabled = false, expDate = "9999-12-31")
+ }
+
+ @Module
+ class TestModule {
+ companion object {
+ var buildFlavor = BuildFlavor.BUILD_FLAVOR_UNSPECIFIED
+ }
+
+ @Provides
+ @Singleton
+ fun provideContext(application: Application): Context {
+ return application
+ }
+
+ // TODO(#59): Either isolate these to their own shared test module, or use the real logging
+ // module in tests to avoid needing to specify these settings for tests.
+ @EnableConsoleLog
+ @Provides
+ fun provideEnableConsoleLog(): Boolean = true
+
+ @EnableFileLog
+ @Provides
+ fun provideEnableFileLog(): Boolean = false
+
+ @GlobalLogLevel
+ @Provides
+ fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE
+
+ @Provides
+ fun provideTestingBuildFlavor(): BuildFlavor = buildFlavor
+ }
+
+ @Singleton
+ @Component(
+ modules = [
+ LogStorageModule::class, RobolectricModule::class,
+ TestModule::class, TestDispatcherModule::class, TestLogReportingModule::class,
+ NetworkConnectionUtilDebugModule::class,
+ OppiaClockModule::class, LocaleProdModule::class,
+ ExpirationMetaDataRetrieverModule::class, // Use real implementation to test closer to prod.
+ LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
+ SyncStatusModule::class, PlatformParameterModule::class,
+ PlatformParameterSingletonModule::class
+ ]
+ )
+ interface TestApplicationComponent : DataProvidersInjector {
+ @Component.Builder
+ interface Builder {
+ @BindsInstance
+ fun setApplication(application: Application): Builder
+
+ fun build(): TestApplicationComponent
+ }
+
+ fun getDeprecationController(): DeprecationController
+
+ fun getCacheFactory(): PersistentCacheStore.Factory
+
+ fun getTestCoroutineDispatchers(): TestCoroutineDispatchers
+
+ fun getContext(): Context
+
+ fun inject(deprecationControllerTest: DeprecationControllerTest)
+ }
+
+ class TestApplication : Application(), DataProvidersInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerDeprecationControllerTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build()
+ }
+
+ fun inject(deprecationControllerTest: DeprecationControllerTest) {
+ component.inject(deprecationControllerTest)
+ }
+
+ public override fun attachBaseContext(base: Context?) {
+ super.attachBaseContext(base)
+ }
+
+ override fun getDataProvidersInjector(): DataProvidersInjector = component
+ }
+}
diff --git a/model/src/main/proto/BUILD.bazel b/model/src/main/proto/BUILD.bazel
index 3993aeee960..a168e981191 100644
--- a/model/src/main/proto/BUILD.bazel
+++ b/model/src/main/proto/BUILD.bazel
@@ -154,6 +154,18 @@ java_lite_proto_library(
deps = [":onboarding_proto"],
)
+oppia_proto_library(
+ name = "deprecation_proto",
+ srcs = ["deprecation.proto"],
+ deps = [":version_proto"],
+)
+
+java_lite_proto_library(
+ name = "deprecation_java_proto_lite",
+ visibility = ["//:oppia_api_visibility"],
+ deps = [":deprecation_proto"],
+)
+
oppia_proto_library(
name = "spotlight_proto",
srcs = ["spotlight.proto"],
diff --git a/model/src/main/proto/deprecation.proto b/model/src/main/proto/deprecation.proto
new file mode 100644
index 00000000000..251109f0c23
--- /dev/null
+++ b/model/src/main/proto/deprecation.proto
@@ -0,0 +1,47 @@
+syntax = "proto3";
+
+package model;
+
+import "version.proto";
+
+option java_package = "org.oppia.android.app.model";
+option java_multiple_files = true;
+
+// Top-level proto used to store deprecation responses, which correspond to a user's interaction
+// with a dialog notifying them of either an optional update or of an OS deprecation.
+message DeprecationResponseDatabase {
+ // Stores a user's response to a dialog notifying the user that an optional update is available
+ // for download.
+ DeprecationResponse app_deprecation_response = 1;
+
+ // Stores a user's response to a dialog notifying the user that their OS is deprecated.
+ DeprecationResponse os_deprecation_response = 2;
+}
+
+// Represents a response to a dialog notifying the user that an optional update is available for
+// download, or that their OS is deprecated.
+message DeprecationResponse {
+ // The app version of the latest available update at the time the user dismissed a deprecation
+ // notice dialog.
+ int32 deprecated_version = 1;
+
+ // The timestamp in milliseconds since epoch corresponding to the date and time that the user
+ // dismissed the deprecation notice dialog.
+ uint64 notice_dismissed_timestamp_millis = 2;
+
+ // The [DeprecationNoticeType] of the dialog that the user has dismissed. This can
+ // either be unspecified, an app deprecation or an OS deprecation notice.
+ DeprecationNoticeType deprecation_notice_type = 3;
+}
+
+// An enum object that represents the different deprecation notices that can be shown to the user.
+enum DeprecationNoticeType {
+ // Unspecified notice type.
+ DEPRECATION_NOTICE_TYPE_UNSPECIFIED = 0;
+
+ // App update notice type.
+ APP_DEPRECATION = 1;
+
+ // OS deprecation notice type.
+ OS_DEPRECATION = 2;
+}
diff --git a/model/src/main/proto/onboarding.proto b/model/src/main/proto/onboarding.proto
index 3ed97a65062..4cefc9213d7 100644
--- a/model/src/main/proto/onboarding.proto
+++ b/model/src/main/proto/onboarding.proto
@@ -24,6 +24,15 @@ message AppStartupState {
// continue using it. Instead, they should be shown a prompt suggesting that they update the app
// via the Play Store.
APP_IS_DEPRECATED = 3;
+
+ // Indicates that a new app version is available and the user should be shown a prompt to update
+ // the app. Since the update is optional, the user can choose to update or not.
+ OPTIONAL_UPDATE_AVAILABLE = 4;
+
+ // Indicates that a new app version is available but the user can not update the app because
+ // they are using an OS version that is no longer supported. The user should be shown a prompt
+ // to update their OS.
+ OS_IS_DEPRECATED = 5;
}
// Describes different notices that may be shown to the user on startup depending on whether
diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto
index 40a355c58f2..677e7ebcb9e 100644
--- a/scripts/assets/file_content_validation_checks.textproto
+++ b/scripts/assets/file_content_validation_checks.textproto
@@ -331,6 +331,7 @@ file_content_checks {
exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/PolynomialExtensionsTest.kt"
exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt"
exempted_file_name: "utility/src/test/java/org/oppia/android/util/profile/ProfileNameValidatorTest.kt"
+ exempted_file_name: "domain/src/test/java/org/oppia/android/domain/onboarding/DeprecationControllerTest.kt"
exempted_file_patterns: "testing/src/main/java/org/oppia/android/testing/junit/.+?\\.kt"
}
file_content_checks {
From 12ded96886d29d46682e837c3232c4b6d3377800 Mon Sep 17 00:00:00 2001
From: Adhiambo Peres <59600948+adhiamboperes@users.noreply.github.com>
Date: Fri, 14 Jul 2023 21:07:38 +0300
Subject: [PATCH 11/33] Fix #5088: Revert ""Fix #4710 the button scaling
problem in largest text, and change button name"" (#5090)
Fixes #5088.
Reverts oppia/oppia-android#4987 which removed styles and added changes
that broke the dark mode support previously correctly implmented in
#4796.
This is a clean revert so it can be merged directly.
| Before - Light Mode | Before - Dark Mode |
| ------ | ------ |
| | |
| After - Light Mode | After - Dark Mode |
| ------ | ------ |
| | |
| Before - Dark Mode Landscape | After - Dark Mode Landscape |
| ------ | ------ |
| |
|
| Before - Dark Mode Tab | After - Dark Mode Tab |
| ------ | ------ |
| | |
---
.../drawable/secondary_button_background.xml | 2 +-
.../layout-land/resume_lesson_fragment.xml | 59 ++++++-------------
.../resume_lesson_fragment.xml | 56 ++++++------------
.../res/layout/resume_lesson_fragment.xml | 57 ++++++------------
app/src/main/res/values/styles.xml | 26 +++++++-
5 files changed, 77 insertions(+), 123 deletions(-)
diff --git a/app/src/main/res/drawable/secondary_button_background.xml b/app/src/main/res/drawable/secondary_button_background.xml
index 98cd3d0fdb6..2debad327a0 100644
--- a/app/src/main/res/drawable/secondary_button_background.xml
+++ b/app/src/main/res/drawable/secondary_button_background.xml
@@ -2,7 +2,7 @@
-
+
diff --git a/app/src/main/res/layout-land/resume_lesson_fragment.xml b/app/src/main/res/layout-land/resume_lesson_fragment.xml
index b620e949ba8..5c9702b26f8 100644
--- a/app/src/main/res/layout-land/resume_lesson_fragment.xml
+++ b/app/src/main/res/layout-land/resume_lesson_fragment.xml
@@ -1,6 +1,5 @@
-
@@ -55,58 +54,36 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/resume_lesson_chapter_title_text_view" />
-
-
-
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/resume_lesson_continue_button"
+ app:layout_constraintHorizontal_chainStyle="spread"
+ app:layout_constraintStart_toStartOf="parent" />
-
-
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toEndOf="@id/resume_lesson_start_over_button" />
+
diff --git a/app/src/main/res/layout-sw600dp-land/resume_lesson_fragment.xml b/app/src/main/res/layout-sw600dp-land/resume_lesson_fragment.xml
index 02f728f78df..1c7c98925cd 100644
--- a/app/src/main/res/layout-sw600dp-land/resume_lesson_fragment.xml
+++ b/app/src/main/res/layout-sw600dp-land/resume_lesson_fragment.xml
@@ -85,58 +85,36 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/resume_lesson_chapter_title_text_view" />
-
-
-
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/resume_lesson_continue_button"
+ app:layout_constraintHorizontal_chainStyle="spread"
+ app:layout_constraintStart_toStartOf="parent" />
-
-
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toEndOf="@id/resume_lesson_start_over_button" />
+
diff --git a/app/src/main/res/layout/resume_lesson_fragment.xml b/app/src/main/res/layout/resume_lesson_fragment.xml
index 9d8c50d9526..c1fe33dd70b 100644
--- a/app/src/main/res/layout/resume_lesson_fragment.xml
+++ b/app/src/main/res/layout/resume_lesson_fragment.xml
@@ -69,59 +69,36 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/resume_lesson_chapter_title_text_view" />
-
-
-
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/resume_lesson_continue_button"
+ app:layout_constraintHorizontal_chainStyle="spread"
+ app:layout_constraintStart_toStartOf="parent" />
-
-
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toEndOf="@id/resume_lesson_start_over_button" />
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 24bc4f073a0..c86d564fef6 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -411,17 +411,39 @@
+
+
+
From a2fdc7ad8d0471fc67241848a881043ceb709dd0 Mon Sep 17 00:00:00 2001
From: PRAFUL BANDRE <109658545+prafulbbandre@users.noreply.github.com>
Date: Thu, 20 Jul 2023 17:37:21 +0530
Subject: [PATCH 12/33] Fix : #4793 (#5092)
## Explanation
Fixes #4793
## Essential Checklist
- [X] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [X] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [X] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [X] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [X] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [X] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
https://github.com/oppia/oppia-android/assets/109658545/e542f6fc-02fc-43de-a413-93fe1e49bc04
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---
app/src/main/res/values/strings.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cad5b0772ed..714be87502e 100755
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -453,7 +453,7 @@
No new hint availableShow hints and solutionHint %s
- Navigate up
+ CloseHintsShow solutionShow Solution
From df820f7364b1383993f47fc3f9b320c3557d5835 Mon Sep 17 00:00:00 2001
From: Abinash <110354418+Abinash6000@users.noreply.github.com>
Date: Tue, 25 Jul 2023 16:20:30 +0530
Subject: [PATCH 13/33] Fixes #4736: revision card textViews line wraps instead
of ellipsize and solves accessibility scanner issue (#5095)
## Explanation
Fixes #4736: This PR increases the number of lines of
```prev_subtopic_title``` and ```next_subtopic_title``` if the text
doesn't fit within 3 lines for large texts. And solves the accessiblity
scanner issue.
![image](https://github.com/oppia/oppia-android/assets/110354418/c6006977-45fb-4115-9e1e-298c613294e8)![image](https://github.com/oppia/oppia-android/assets/110354418/1c106d95-f618-4104-8e31-76d58c0ba490)
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- (before)
-
![image](https://github.com/oppia/oppia-android/assets/110354418/4ffd95c3-f445-4142-a2b2-0e4fd8f743d9)
-
![image](https://github.com/oppia/oppia-android/assets/110354418/33e813a4-f404-4f93-86a6-e8075688d2e4)
- (after)
-
![image](https://github.com/oppia/oppia-android/assets/110354418/54356eef-4a60-49c3-a721-70000437879e)
-
![image](https://github.com/oppia/oppia-android/assets/110354418/d07b7511-a682-4a27-acaa-5788cff1d08c)
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
-
![image](https://github.com/oppia/oppia-android/assets/110354418/5ded08c7-058d-4aaf-a806-a4239851fbe6)
-
![image](https://github.com/oppia/oppia-android/assets/110354418/13efd801-ec31-4adb-9c0d-9e0fdc60b88b)
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---
.../res/layout-sw600dp/revision_card_fragment.xml | 12 ++++++++----
app/src/main/res/layout/revision_card_fragment.xml | 12 ++++++++----
2 files changed, 16 insertions(+), 8 deletions(-)
diff --git a/app/src/main/res/layout-sw600dp/revision_card_fragment.xml b/app/src/main/res/layout-sw600dp/revision_card_fragment.xml
index e10815ad3e1..a7a2dc5081f 100644
--- a/app/src/main/res/layout-sw600dp/revision_card_fragment.xml
+++ b/app/src/main/res/layout-sw600dp/revision_card_fragment.xml
@@ -59,8 +59,9 @@
Date: Tue, 25 Jul 2023 13:12:59 -0600
Subject: [PATCH 14/33] Fix some typo in string file (#5105)
## Explanation
Fix some typo in string file
## Essential Checklist
- [ ] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [ ] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---
.../hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt | 2 +-
app/src/main/res/values-ar/strings.xml | 2 +-
app/src/main/res/values-pcm-rNG/strings.xml | 2 +-
app/src/main/res/values-pt-rBR/strings.xml | 2 +-
app/src/main/res/values-sw/strings.xml | 2 +-
app/src/main/res/values/strings.xml | 2 +-
6 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt
index c7581956509..670afb8c3c5 100644
--- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt
@@ -92,7 +92,7 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor(
HintsAndSolutionFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
binding.hintsAndSolutionToolbar.setNavigationIcon(R.drawable.ic_close_white_24dp)
binding.hintsAndSolutionToolbar.setNavigationContentDescription(
- R.string.hints_andSolution_close_icon_description
+ R.string.hints_and_solution_close_icon_description
)
binding.hintsAndSolutionToolbar.setNavigationOnClickListener {
(fragment.requireActivity() as? HintsAndSolutionListener)?.dismiss()
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 3e771dcccde..8561ab8aea0 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -418,7 +418,7 @@
لا يوجد تلميح جديد متاحإظهار الملاحظات والحلتلميح %s
- العودة للسابق
+ العودة للسابقالملاحظاتعرض الحلعرض الحل
diff --git a/app/src/main/res/values-pcm-rNG/strings.xml b/app/src/main/res/values-pcm-rNG/strings.xml
index 41dea5c3849..f9039a39324 100644
--- a/app/src/main/res/values-pcm-rNG/strings.xml
+++ b/app/src/main/res/values-pcm-rNG/strings.xml
@@ -392,7 +392,7 @@
No new hint deyShow hints and solutionHint %s
- Go up
+ Go upHintsShow solutionShow Solution
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index a9fa5c5220b..41cf187340b 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -380,7 +380,7 @@
Nova dica disponívelNova dica disponívelMostrar dicas e solução
- Navegar para cima
+ Navegar para cimaDicasExibirRevelar Solução
diff --git a/app/src/main/res/values-sw/strings.xml b/app/src/main/res/values-sw/strings.xml
index 5ab6efddf76..a2c45731ef9 100644
--- a/app/src/main/res/values-sw/strings.xml
+++ b/app/src/main/res/values-sw/strings.xml
@@ -356,7 +356,7 @@
Je, unavutiwa na:\n%s?Kidokezo kipya kinapatikanaOnyesha vidokezo na suluhisho
- Nenda juu
+ Nenda juuVidokezoFichua SuluhishoFichua Kidokezo
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 714be87502e..cc6da97c717 100755
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -453,7 +453,7 @@
No new hint availableShow hints and solutionHint %s
- Close
+ CloseHintsShow solutionShow Solution
From 67fb399745c0b33cca88561d96e0e41dfd999722 Mon Sep 17 00:00:00 2001
From: "translatewiki.net"
Date: Thu, 27 Jul 2023 18:38:48 +0530
Subject: [PATCH 15/33] Localisation updates from https://translatewiki.net.
(#5109)
Translation updates
---
app/src/main/res/values-ar/strings.xml | 2 +-
app/src/main/res/values-pcm-rNG/strings.xml | 2 +-
app/src/main/res/values-pt-rBR/strings.xml | 2 +-
app/src/main/res/values-sw/strings.xml | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 8561ab8aea0..938f0dbc081 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -418,7 +418,7 @@
لا يوجد تلميح جديد متاحإظهار الملاحظات والحلتلميح %s
- العودة للسابق
+ العودة للسابقالملاحظاتعرض الحلعرض الحل
diff --git a/app/src/main/res/values-pcm-rNG/strings.xml b/app/src/main/res/values-pcm-rNG/strings.xml
index f9039a39324..616d6e03f76 100644
--- a/app/src/main/res/values-pcm-rNG/strings.xml
+++ b/app/src/main/res/values-pcm-rNG/strings.xml
@@ -392,7 +392,7 @@
No new hint deyShow hints and solutionHint %s
- Go up
+ Go upHintsShow solutionShow Solution
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 41cf187340b..9cb7c841127 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -380,7 +380,7 @@
Nova dica disponívelNova dica disponívelMostrar dicas e solução
- Navegar para cima
+ Navegar para cimaDicasExibirRevelar Solução
diff --git a/app/src/main/res/values-sw/strings.xml b/app/src/main/res/values-sw/strings.xml
index a2c45731ef9..1cf7fc4433c 100644
--- a/app/src/main/res/values-sw/strings.xml
+++ b/app/src/main/res/values-sw/strings.xml
@@ -356,7 +356,7 @@
Je, unavutiwa na:\n%s?Kidokezo kipya kinapatikanaOnyesha vidokezo na suluhisho
- Nenda juu
+ FungaVidokezoFichua SuluhishoFichua Kidokezo
From faf148b24241b37636d8f39bcdedc15bf480dd49 Mon Sep 17 00:00:00 2001
From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com>
Date: Fri, 28 Jul 2023 05:47:54 -0600
Subject: [PATCH 16/33] Fix #5017 : Updated example screenshots in wiki as per
new oppia-android ui (#5107)
## Explanation
Fix #5017 : Updated example screenshots in wiki as per new oppia-android
ui
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [ ] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---
...Oppia-Android-codebase-and-architecture.md | 26 +++++++++----------
1 file changed, 13 insertions(+), 13 deletions(-)
diff --git a/wiki/Overview-of-the-Oppia-Android-codebase-and-architecture.md b/wiki/Overview-of-the-Oppia-Android-codebase-and-architecture.md
index 692e243066a..31c790b5aca 100644
--- a/wiki/Overview-of-the-Oppia-Android-codebase-and-architecture.md
+++ b/wiki/Overview-of-the-Oppia-Android-codebase-and-architecture.md
@@ -152,33 +152,33 @@ Following these steps would lead to completing the entire task with all the code
**Task:**
-Finding code from a string ( e g., topic description under topic info tab) that you see in UI when running the app all the way to the UI components, domain controllers and the tests ultimately behind that text appearing.
+Finding code from a string ( e g., story name under lessons tab) that you see in UI when running the app all the way to the UI components, domain controllers and the tests ultimately behind that text appearing.
-
+
**Walkthrough:**
-**Finding the UI component (topic description)**
+**Finding the UI component (story name)**
1. The first step is to identify the id of the UI component that is responsible for displaying the text. We can do this by using the layout inspector of the android studio.
-2. To do this, run the app on an emulator. Now navigate to the screen that displays the UI component, i.e. the topic info tab.
+2. To do this, run the app on an emulator. Now navigate to the screen that displays the UI component, i.e. the lessons tab.
-3. Next, open the layout inspector from the android studio, and click on the UI component displaying the topic description. Now all the attributes of this UI component are displayed on the right side of the layout inspector. Here, you can see this UI component's id, i.e. topic_description text_view.
+3. Next, open the layout inspector from the android studio, and click on the UI component displaying the story name. Now all the attributes of this UI component are displayed on the right side of the layout inspector. Here, you can see this UI component's id, i.e. story_name_text_view.
-
+
-4. Now we have to find the file with a UI component with this id. We can do this by pressing double shift and then typing the id. Doing this, we see the id is the id of a text view present in the file topic_info_fragment.xml.
+4. Now we have to find the file with a UI component with this id. We can do this by pressing double shift and then typing the id. Doing this, we see the id is the id of a text view present in the file topic_lessons_story_summary.xml.
-5. Now that we know that the text view is present in topic_info_fragment.xml, according to the app architecture, we know that the name of this fragment is TopicInfoFragment. The files responsible for displaying this fragment are TopicInfoFragment.kt and TopicInfoFragmentPresenter.kt.
+5. Now that we know that the text view is present in topic_lessons_story_summary.xml, according to the app architecture, we know that the name of this fragment is TopicLessonsFragment. The files responsible for displaying this fragment are TopicLessonsFragment.kt and TopicLessonsFragmentPresenter.kt.
-6. Looking at the XML code for topic_description_text_view, we can see that TopicInfoViewModel sets the text in the text view using databinding.
+6. Looking at the XML code for story_name_text_view, we can see that StorySummaryViewModel sets the text in the text view using databinding.
**Finding the business logic for the UI component, i.e. domain controllers**
-1. Following the app architecture used by Oppia, TopicInfoViewModel should be initialized in the TopicInfoFragmentPresenter.
+1. Following the app architecture used by Oppia, StorySummaryViewModel should be initialized in the TopicLessonsFragmentPresenter.
-2. Here we can see that the topic description is being updated in the viewModel by the TopicController. Therefore the business logic for getting the topic description will be present in the file TopicController.
+2. Here we can see that the story name is being updated in the viewModel by the TranslationController. Therefore the business logic for getting the story name will be present in the file TranslationController.
**Finding the tests**
@@ -186,9 +186,9 @@ There are two sets of tests:
- Tests to test the UI component
- Tests to test the business logic of the UI component
-Since the UI component is present in the TopicInfoFramgnet, the UI component tests are present in the file TopicInfoFragmentTest.
+Since the UI component is present in the TopicLessonsFragment, the UI component tests are present in the file TopicLessonsFragmentTest.
-Similarly, since the business logic is present in the file TopicController, the tests for this controller can be found in the file TopicControllerTest.
+Similarly, since the business logic is present in the file TranslationController, the tests for this controller can be found in the file TranslationControllerTest.
## Dependency Injection
From 3e81ff6a19e61df69348336fef2c7c7be0e77597 Mon Sep 17 00:00:00 2001
From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com>
Date: Fri, 28 Jul 2023 14:46:12 -0600
Subject: [PATCH 17/33] Fix part of #3690 : Wiki update - How to run static CI
check locally (#5108)
## Explanation
Fix part of #3690 : Wiki Update - How to run static CI check locally
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [ ] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---
scripts/assets/todo_open_exemptions.textproto | 24 +++++++++----------
wiki/Static-Analysis-Checks.md | 9 +++++++
2 files changed, 21 insertions(+), 12 deletions(-)
diff --git a/scripts/assets/todo_open_exemptions.textproto b/scripts/assets/todo_open_exemptions.textproto
index 187cb76308c..0fa81fd297d 100644
--- a/scripts/assets/todo_open_exemptions.textproto
+++ b/scripts/assets/todo_open_exemptions.textproto
@@ -294,16 +294,16 @@ todo_open_exemption {
todo_open_exemption {
exempted_file_path: "wiki/Static-Analysis-Checks.md"
- line_number: 169
- line_number: 177
- line_number: 182
- line_number: 186
- line_number: 190
- line_number: 192
- line_number: 207
- line_number: 217
- line_number: 220
- line_number: 223
- line_number: 226
- line_number: 229
+ line_number: 170
+ line_number: 178
+ line_number: 183
+ line_number: 187
+ line_number: 191
+ line_number: 193
+ line_number: 208
+ line_number: 218
+ line_number: 221
+ line_number: 224
+ line_number: 227
+ line_number: 230
}
diff --git a/wiki/Static-Analysis-Checks.md b/wiki/Static-Analysis-Checks.md
index 5b82308601d..75acc335fba 100644
--- a/wiki/Static-Analysis-Checks.md
+++ b/wiki/Static-Analysis-Checks.md
@@ -11,6 +11,7 @@
- [KDoc validity check](#kdoc-validity-check)
- [TODO open checks](#todo-open-checks)
- [TODO issue resolved check](#todo-issue-resolved-check)
+ - [How to run static checks locally](#how-to-run-static-checks-locally)
# Background
Static analysis is a method of debugging by examining source code before a program is run. It’s done by analyzing a set of code against a set (or multiple sets) of coding rules.
@@ -227,3 +228,11 @@ To fix the failures for this check: resolve the TODO items and then close the is
# How to add a new Static analysis check
// TODO([#3690](https://github.com/oppia/oppia-android/issues/3690)): Complete static checks Wiki
+
+# How to run static checks locally
+
+- Go to the failing CI check in your GitHub PR.
+- Scroll to the top of the failing CI check logs, and find the Bazel command that was run for this script.
+- Alternatively, in Android Studio, go to the `.github` folder and find the [static_checks.yml](https://github.com/oppia/oppia-android/blob/develop/.github/workflows/static_checks.yml) file. Search for the line that corresponds to the name of the job that failed. You can then run the same script on your local terminal.
+
+Note: Before running the script command in your local terminal, make sure you have Bazel installed. To learn how to set up Bazel for Oppia Android, follow these [instructions](https://github.com/oppia/oppia-android/wiki/Oppia-Bazel-Setup-Instructions).
From a888729f3270bef8f04f71ac1cd5f50d2db42363 Mon Sep 17 00:00:00 2001
From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com>
Date: Tue, 1 Aug 2023 13:12:52 -0600
Subject: [PATCH 18/33] Fix #5086 : Implement Dark Mode support for NPS Survey
(#5104)
## Explanation
Fix #5086 : Implement full Dark Mode support for NPS Survey
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [ ] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
### NPS Survey Welcome Screen
| Light Mode | Dark Mode |
| ------ | ------ |
| | |
### NPS Survey Question screen (Button Disabled)
| Light Mode | Dark Mode |
| ------ | ------ |
| | |
### NPS Survey Question screen (Button Enabled)
| Light Mode | Dark Mode |
| ------ | ------ |
| | |
### NPS Survey Score Screen
| Light Mode | Dark Mode |
| ------ | ------ |
| | |
### NPS Survey Final comment screen (Button Enabled)
| Light Mode | Dark Mode |
| ------ | ------ |
| | |
### NPS Survey Final comment screen (Button Disabled)
| Light Mode | Dark Mode |
| ------ | ------ |
| | |
### NPS Survey Thanks You screen
| Light Mode | Dark Mode |
| ------ | ------ |
| | |
### NPS Survey Exit Dialog
| Light Mode | Dark Mode |
| ------ | ------ |
| | |
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
-
---------
Co-authored-by: Adhiambo Peres <59600948+adhiamboperes@users.noreply.github.com>
---
app/src/main/AndroidManifest.xml | 2 +-
.../SurveyOnboardingBackgroundView.kt | 2 +-
.../ExplorationActivityPresenter.kt | 4 +-
.../ExitSurveyConfirmationDialogFragment.kt | 3 +
.../android/app/survey/SurveyActivity.kt | 11 +-
.../SurveyWelcomeDialogFragmentPresenter.kt | 5 +-
.../drawable/nps_survey_button_background.xml | 9 ++
.../nps_survey_button_inactive_background.xml | 9 ++
.../rounded_button_white_background_color.xml | 4 +-
.../rounded_button_white_outline_color.xml | 4 +-
.../survey_confirmation_dialog_background.xml | 2 +-
.../survey_next_button_background.xml | 7 -
.../survey_nps_radio_selected_color.xml | 2 +-
.../drawable/survey_nps_radio_text_color.xml | 4 +-
.../survey_nps_radio_unselected_color.xml | 2 +-
.../main/res/drawable/survey_progress_bar.xml | 4 +-
.../survey_submit_button_background.xml | 7 -
.../main/res/layout-land/survey_fragment.xml | 146 ++++++++++++++++++
.../survey_welcome_dialog_fragment.xml | 68 ++++++++
.../survey_welcome_dialog_fragment.xml | 68 ++++++++
.../survey_exit_confirmation_dialog.xml | 3 +-
app/src/main/res/layout/survey_fragment.xml | 8 +-
.../res/layout/survey_free_form_layout.xml | 1 +
.../main/res/values-night/color_palette.xml | 21 +++
app/src/main/res/values/color_defs.xml | 4 +-
app/src/main/res/values/color_palette.xml | 16 +-
app/src/main/res/values/component_colors.xml | 14 +-
app/src/main/res/values/dimens.xml | 2 +
app/src/main/res/values/styles.xml | 23 +--
29 files changed, 404 insertions(+), 51 deletions(-)
create mode 100644 app/src/main/res/drawable/nps_survey_button_background.xml
create mode 100644 app/src/main/res/drawable/nps_survey_button_inactive_background.xml
delete mode 100644 app/src/main/res/drawable/survey_next_button_background.xml
delete mode 100644 app/src/main/res/drawable/survey_submit_button_background.xml
create mode 100644 app/src/main/res/layout-land/survey_fragment.xml
create mode 100644 app/src/main/res/layout-land/survey_welcome_dialog_fragment.xml
create mode 100644 app/src/main/res/layout-w600dp/survey_welcome_dialog_fragment.xml
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bad515549bb..a27551a2005 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -311,7 +311,7 @@
android:name=".app.survey.SurveyActivity"
android:label="@string/survey_activity_title"
android:theme="@style/OppiaThemeWithoutActionBar"
- android:windowSoftInputMode="adjustResize" />
+ android:windowSoftInputMode="adjustNothing" />
+
+
+
+
+
diff --git a/app/src/main/res/drawable/nps_survey_button_inactive_background.xml b/app/src/main/res/drawable/nps_survey_button_inactive_background.xml
new file mode 100644
index 00000000000..c6d563ecc99
--- /dev/null
+++ b/app/src/main/res/drawable/nps_survey_button_inactive_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_button_white_background_color.xml b/app/src/main/res/drawable/rounded_button_white_background_color.xml
index a11197bd37a..196f7707443 100644
--- a/app/src/main/res/drawable/rounded_button_white_background_color.xml
+++ b/app/src/main/res/drawable/rounded_button_white_background_color.xml
@@ -2,8 +2,8 @@
-
+
+ android:color="@color/component_color_shared_button_active_stroke_color" />
diff --git a/app/src/main/res/drawable/rounded_button_white_outline_color.xml b/app/src/main/res/drawable/rounded_button_white_outline_color.xml
index 9745cd92363..e0062e45189 100644
--- a/app/src/main/res/drawable/rounded_button_white_outline_color.xml
+++ b/app/src/main/res/drawable/rounded_button_white_outline_color.xml
@@ -2,8 +2,8 @@
-
+
+ android:color="@color/component_color_survey_white_button_color" />
diff --git a/app/src/main/res/drawable/survey_confirmation_dialog_background.xml b/app/src/main/res/drawable/survey_confirmation_dialog_background.xml
index a8e76a82fd6..4ccc77e6490 100644
--- a/app/src/main/res/drawable/survey_confirmation_dialog_background.xml
+++ b/app/src/main/res/drawable/survey_confirmation_dialog_background.xml
@@ -2,7 +2,7 @@
-
+
diff --git a/app/src/main/res/drawable/survey_next_button_background.xml b/app/src/main/res/drawable/survey_next_button_background.xml
deleted file mode 100644
index de55a5e10e0..00000000000
--- a/app/src/main/res/drawable/survey_next_button_background.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/survey_nps_radio_selected_color.xml b/app/src/main/res/drawable/survey_nps_radio_selected_color.xml
index c62b0773a38..0019620da24 100644
--- a/app/src/main/res/drawable/survey_nps_radio_selected_color.xml
+++ b/app/src/main/res/drawable/survey_nps_radio_selected_color.xml
@@ -1,7 +1,7 @@
-
-
+
+
diff --git a/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml b/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml
index b2665b4d899..ac3f393f2fe 100644
--- a/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml
+++ b/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml
@@ -1,7 +1,7 @@
-
+
+ android:color="@color/component_color_shared_white_background_color" />
diff --git a/app/src/main/res/drawable/survey_submit_button_background.xml b/app/src/main/res/drawable/survey_submit_button_background.xml
deleted file mode 100644
index f59c05a5547..00000000000
--- a/app/src/main/res/drawable/survey_submit_button_background.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout-land/survey_fragment.xml b/app/src/main/res/layout-land/survey_fragment.xml
new file mode 100644
index 00000000000..d1358390ce6
--- /dev/null
+++ b/app/src/main/res/layout-land/survey_fragment.xml
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-land/survey_welcome_dialog_fragment.xml b/app/src/main/res/layout-land/survey_welcome_dialog_fragment.xml
new file mode 100644
index 00000000000..9a41b4cf558
--- /dev/null
+++ b/app/src/main/res/layout-land/survey_welcome_dialog_fragment.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-w600dp/survey_welcome_dialog_fragment.xml b/app/src/main/res/layout-w600dp/survey_welcome_dialog_fragment.xml
new file mode 100644
index 00000000000..d140d187690
--- /dev/null
+++ b/app/src/main/res/layout-w600dp/survey_welcome_dialog_fragment.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_exit_confirmation_dialog.xml b/app/src/main/res/layout/survey_exit_confirmation_dialog.xml
index 78128ced3dd..fbbfe690df1 100644
--- a/app/src/main/res/layout/survey_exit_confirmation_dialog.xml
+++ b/app/src/main/res/layout/survey_exit_confirmation_dialog.xml
@@ -11,7 +11,8 @@
style="@style/SurveyQuestionTextStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_margin="@dimen/survey_question_text_margin"
+ android:layout_marginStart="@dimen/survey_question_text_margin"
+ android:layout_marginEnd="@dimen/survey_question_text_margin"
android:text="@string/survey_exit_confirmation_text"
app:layout_constraintTop_toTopOf="parent" />
diff --git a/app/src/main/res/layout/survey_fragment.xml b/app/src/main/res/layout/survey_fragment.xml
index 07f6770a3d5..f96a442a1d9 100644
--- a/app/src/main/res/layout/survey_fragment.xml
+++ b/app/src/main/res/layout/survey_fragment.xml
@@ -28,10 +28,10 @@
android:id="@+id/survey_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:background="@color/component_color_shared_secondary_toolbar_color"
+ android:background="@color/component_color_shared_activity_toolbar_color"
+ android:contentDescription="@string/survey_exit_button_description"
android:minHeight="?attr/actionBarSize"
android:theme="@style/Widget.AppCompat.ActionBar"
- android:contentDescription="@string/survey_exit_button_description"
app:navigationIcon="@drawable/ic_close_white_24dp">
@color/color_def_onboarding_4_status_bar
@color/color_def_grape_violet@color/color_def_bright_violet
+
+ @color/color_def_oppia_light_black
+ @color/color_def_white_f6
+ @color/color_def_oppia_turquoise
+ @color/color_def_oppia_green
+ @color/color_def_white_f5
+ @color/color_def_black_grey
+ @color/color_def_dark_green
+ @color/color_def_white
+ @color/color_def_white_f6
+ @color/color_def_dark_green
+ @color/color_def_light_blue
+ @color/color_def_white
+ @color/color_def_oppia_grayish_black
+ @color/color_def_chooser_grey
+ @color/color_def_oppia_green
+ @color/color_def_grey
+ @color/color_def_dark_green
+ @color/color_def_white
+ @color/color_def_oppia_green
+ @color/color_def_pale_green
diff --git a/app/src/main/res/values/color_defs.xml b/app/src/main/res/values/color_defs.xml
index 27498356b7e..446a12a3a70 100644
--- a/app/src/main/res/values/color_defs.xml
+++ b/app/src/main/res/values/color_defs.xml
@@ -137,8 +137,8 @@
#AB29CC#CC29B1#CC2970
- #F5F5F5
- #F6F6F6
+ #F5F5F5
+ #F6F6F6#BDCCCC#E8E8E8
diff --git a/app/src/main/res/values/color_palette.xml b/app/src/main/res/values/color_palette.xml
index 0c34c6b9f99..50e8f6caa30 100644
--- a/app/src/main/res/values/color_palette.xml
+++ b/app/src/main/res/values/color_palette.xml
@@ -245,8 +245,20 @@
@color/color_def_avatar_background_23@color/color_def_avatar_background_24
- @color/color_def_survey_background
- @color/color_def_survey_buttons_white
+ @color/color_def_white_f5
+ @color/color_def_white_f6
+ @color/color_def_oppia_green
+ @color/color_def_oppia_green
+ @color/color_def_oppia_green
+ @color/color_def_oppia_green
+ @color/color_def_oppia_green
+ @color/color_def_white
+ @color/color_def_white_f6
+ @color/color_def_oppia_green
+ @color/color_def_oppia_green
+ @color/color_def_white
+ @color/color_def_oppia_green
+ @color/color_def_pale_green@color/color_def_light_blue@color/color_def_accessible_grey@color/color_def_survey_disabled_button_grey
diff --git a/app/src/main/res/values/component_colors.xml b/app/src/main/res/values/component_colors.xml
index 59b36435596..f72584f767f 100644
--- a/app/src/main/res/values/component_colors.xml
+++ b/app/src/main/res/values/component_colors.xml
@@ -280,7 +280,19 @@
@color/color_palette_survey_background_color@color/color_palette_survey_shared_button_color
- @color/color_palette_primary_color
+ @color/color_palette_survey_previous_button_text_color
+ @color/color_palette_survey_progress_bar_solid_color
+ @color/color_palette_nps_survey_button_inactive_stroke_color
+ @color/color_palette_survey_popup_header_color
+ @color/color_palette_survey_popup_message_color
+ @color/color_palette_survey_popup_background_color
+ @color/color_palette_survey_shared_button_color
+ @color/color_palette_survey_green_button_color
+ @color/color_palette_toolbar_color
+ @color/color_palette_nps_unselected_text_color
+ @color/color_palette_nps_selected_text_color
+ @color/color_palette_nps_unselected_background_color
+ @color/color_palette_nps_selected_background_color@color/color_palette_primary_color@color/color_palette_survey_dialog_stroke_color@color/color_palette_survey_radio_button_color
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 2211fe16ec0..f14ef3f518e 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -777,6 +777,7 @@
28dp28dp28dp
+ 14dp4dp28sp14sp
@@ -784,4 +785,5 @@
32dp8dp16dp
+ 4dp
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index c86d564fef6..d9123efc11d 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -530,7 +530,7 @@
@drawable/rounded_button_white_background_colorsans-serif-medium@dimen/clickable_item_min_height
- true
+ false@color/component_color_begin_survey_button_text_color14sp
@@ -544,7 +544,7 @@
@drawable/rounded_button_white_outline_colorsans-serif-medium@dimen/clickable_item_min_height
- true
+ false@color/component_color_shared_secondary_4_text_color14sp
@@ -557,8 +557,8 @@
@dimen/survey_navigation_buttons_marginsans-serif-medium@dimen/clickable_item_min_height
- true
- @color/component_color_shared_secondary_button_background_trim_color
+ false
+ @color/component_color_survey_previous_button_text_color14sp
@@ -568,9 +568,10 @@
center144dp@dimen/survey_navigation_buttons_margin
+ @drawable/state_button_primary_backgroundsans-serif-medium@dimen/clickable_item_min_height
- true
+ false@color/component_color_shared_secondary_4_text_color14sp
@@ -580,10 +581,10 @@
12dp12dp144dp
- @drawable/survey_submit_button_background
+ @drawable/state_button_primary_backgroundsans-serif-medium@dimen/clickable_item_min_height
- true
+ false@color/component_color_shared_secondary_4_text_color14sp
@@ -612,7 +613,7 @@
-
-
-
From d74576dae6ec2e6a8d14226ae47ab2f87ef6c881 Mon Sep 17 00:00:00 2001
From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com>
Date: Mon, 14 Aug 2023 09:08:50 -0600
Subject: [PATCH 28/33] Fix #4607 : [Developer Video] How to use an
Accessibility Scanner? (#5129)
## Explanation
Fix #4607 : [Developer Video] How to use an Accessibility Scanner?
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [ ] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---
wiki/Accessibility-A11y-Guide.md | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/wiki/Accessibility-A11y-Guide.md b/wiki/Accessibility-A11y-Guide.md
index 7e95ad24bc5..692560e18b2 100644
--- a/wiki/Accessibility-A11y-Guide.md
+++ b/wiki/Accessibility-A11y-Guide.md
@@ -8,6 +8,7 @@
- [Using a11y scanner in android](#using-a11y-scanner-in-android)
- [Using Talkback in android](#using-talkback-in-android)
- [Useful Resources](#useful-resources)
+ - [Developer Videos](#developer-videos)
- [Using AccessibilityTestRule in Espresso Tests](#using-accessibilitytestrule-in-espresso-tests)
- [Auditing the app](#auditing-the-app)
- [General Tips to make app Accessible](#general-tips-to-make-app-accessible)
@@ -31,11 +32,12 @@ There are various manual and automated tests to check if app is accessible by al
**[Accessibility Scanner](https://support.google.com/accessibility/android/answer/6376570?hl=en)** : Using Accessibility Scanner we can take screenshots of each and every screen in the Oppia-app manually and the Accessibility Scanner app will give the output for the individual screenshot mentioning all the errors.
+[Here](https://youtu.be/LF5AgGI5H3A) is a video tutorial on how to set up and use the Accessibility Scanner.
+
**Screen Reader**: Screen readers like **Talkback** can be used to test the app manually. Talkback app is used by blind people to navigate to different items in the screen and get audio based output. This app will not give any error like Accessibility Scanner.
**[AccessibilityChecks](https://developer.android.com/guide/topics/ui/accessibility/testing#automated)**: Developers can activate the `AccessibilityChecks` in all `Espresso` test cases which will give errors related to accessibility.
-
## Setting up Accessibility Scanner and Talkback
### Setup Play Store in mobile emulator
@@ -78,13 +80,15 @@ TalkBack is the Google **screen reader** included on Android devices. TalkBack g
5. Read all the instructions written on the screen as using Talkback requires specific steps.
6. Turn on **Use Service** -> **Allow**
-
### Useful Resources
* [Android A11Y Overview](https://support.google.com/accessibility/android/answer/6006564)
* [Using A11Y Menu](https://support.google.com/accessibility/android/answer/9078941)
* [Getting started with Talkback](https://support.google.com/accessibility/android/answer/6283677)
* [Display speech output as Text: Talkback](https://developer.android.com/guide/topics/ui/accessibility/testing#optional_talkback_developer_settings)
+### Developer Videos
+* [How to use Accessibility Scanner? - Tutorial](https://youtu.be/LF5AgGI5H3A)
+ * [Presentation Slides](https://docs.google.com/presentation/d/1PM_gs3TV2LVKFv6WuF9CUQHWbK7koepAxypzxeZTFzE/edit?usp=sharing)
## Using AccessibilityTestRule in Espresso Tests
[AccessibilityTestRule](https://github.com/oppia/oppia-android/blob/develop/testing/src/main/java/org/oppia/android/testing/AccessibilityTestRule.kt) is a JUnit rule to enable `AccessibilityChecks` in all Espresso Tests. This rule covers all errors shown by Accessibility Scanner and more but only for all those UI elements which are getting used in the test case.
From c5b11fe5126072e929f9bfaab8bb42074016a3c9 Mon Sep 17 00:00:00 2001
From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com>
Date: Tue, 5 Sep 2023 14:13:29 -0600
Subject: [PATCH 29/33] Fix #5094 : [Wiki update] Bazel build failed in mac m1
(#5139)
## Explanation
Fix #5094 : [Wiki update] Bazel build failed in mac m1
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [ ] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---
wiki/Troubleshooting-Installation.md | 36 ++++++++++++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/wiki/Troubleshooting-Installation.md b/wiki/Troubleshooting-Installation.md
index 55d14907a1d..d88c4bf01ad 100644
--- a/wiki/Troubleshooting-Installation.md
+++ b/wiki/Troubleshooting-Installation.md
@@ -10,10 +10,13 @@ Here are some general troubleshooting tips for oppia-android. The specific platf
1. If you find any error related to `cURL`, please set up cURL on your machine. For Linux, you can use `sudo apt install curl`. No need to set up `cURL` for Windows as you are using git bash command line.
+
2. If you find any error which says `java: command not found`, please check you have Java installed correctly in your machine and the [environment path variable](https://www.java.com/en/download/help/path.html) is also set up correctly.
+
3. If you find any error related to Kotlin or Java/Checkstyle while pushing the code, please check [this link](https://github.com/oppia/oppia-android/wiki/Android-Studio-UI-based-Github-workflow#how-to-fix-push-failures).
+
4. If you see the error
```
@@ -22,6 +25,7 @@ Here are some general troubleshooting tips for oppia-android. The specific platf
then please follow the 2nd step mentioned in [this wiki](https://github.com/oppia/oppia-android/wiki/Installing-Oppia-Android#install-oppia-android) for Mac with Apple silicon(M1/M2) chips.
+
5. If you see the error
```
@@ -70,6 +74,7 @@ Here are some general troubleshooting tips for oppia-android. The specific platf
```
[Steps](https://docs.bazel.build/versions/main/tutorial/android-app.html#integrate-with-the-android-sdk) to add ANDROID_HOME environment variable.
+
2. If you encounter the following:
```
@@ -79,11 +84,42 @@ Here are some general troubleshooting tips for oppia-android. The specific platf
Try to delete the `.bazelrc` file to solve the above error.
+
3. **java.lang.ClassNotFoundException: com.android.tools.r8.compatdx.CompatDx**
If, when building the app binary, you encounter a failure that indicates that the CompatDx file cannot be found, this is likely due to you using a newer version of the Android build tools. You can manually downgrade to an older version of build-tools (particularly 29.0.2). Unfortunately, this can't be done through Android Studio but it can be done over a terminal. Follow the instructions listed [here](https://github.com/oppia/oppia-android/issues/3024#issuecomment-884513455) to downgrade your build tools & then try to build the app again.
+4. If you encounter this error while building bazel in Mac M1:
+ ```
+ ERROR: /Users/OpenSource/oppia-android/model/src/main/proto/BUILD.bazel:167:20: Generating JavaLite proto_library //model/src/main/proto:profile_proto failed: (Segmentation fault): protoc failed: error executing command bazel-out/darwin-opt-exec-2B5CBBC6/bin/external/com_google_protobuf/protoc '--proto_path=bazel-out/android-armeabi-v7a-fastbuild/bin/model/src/main/proto/_virtual_imports/languages_proto' ... (remaining 8 argument(s) skipped)
+
+ Use --sandbox_debug to see verbose messages from the sandbox protoc failed: error executing command bazel-out/darwin-opt-exec-2B5CBBC6/bin/external/com_google_protobuf/protoc '--proto_path=bazel-out/android-armeabi-v7a-fastbuild/bin/model/src/main/proto/_virtual_imports/languages_proto' ... (remaining 8 argument(s) skipped)
+ ```
+Bazel requires Xcode commandline tools to build on M1, and the Xcode license also needs to be accepted.
+
+ **Follow these steps to solve this error:**
+
+- Install the commandline tools: `xcode-select --install`
+
+- Accept the Xcode licence: `sudo xcodebuild -licence`
+
+- Reset the xcode select path: `sudo xcode-select -r `
+
+ - Set the xcode select path to use CommandLineTools: `sudo xcode-select -s /Library/Developer/CommandLineTools`
+
+- Confirm that the path was correctly set. The expected output is: `/Library/Developer/CommandLineTools`
+
+ xcode-select -p
+
+After successfully running the above commands, build the app using Bazel by running the following command:
+
+ ```
+ bazel clean --expunge
+ bazel build //:oppia --noexperimental_check_desugar_deps
+ ```
+The `--noexperimental_check_desugar_deps` flag is explained in the [bazel blog](https://blog.bazel.build/2018/12/19/bazel-0.21.html#android).
+
### Can’t find a particular issue?
If the error you get is not in the Troubleshooting section above, please create an issue providing all the necessary information and assign it to **@MohitGupta121**.
From 7668bf08235faca71c07858293158766487aa0bf Mon Sep 17 00:00:00 2001
From: Vishwajith Shettigar
<76042077+Vishwajith-Shettigar@users.noreply.github.com>
Date: Wed, 6 Sep 2023 02:25:41 +0530
Subject: [PATCH 30/33] Fix #4735 Text unit is set in dp instead of sp (#5134)
## Explanation
Fix #4735 , Added toolbar in admin_pin_activity xml file inorder to
control the title size using sp.
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
Before fix
Admin pin activity
| Light mode|
|--------|
|![before
light](https://github.com/oppia/oppia-android/assets/76042077/e80cd747-b04c-43c5-96b9-848dc5db3400)
|
After fix
|Light mode| Dark mode|
|-----------|--------------|
|
![afterlightmode](https://github.com/oppia/oppia-android/assets/76042077/4445ebd1-3fde-40f4-a4cb-cb3716aad43e)
|
![afterdarkmode](https://github.com/oppia/oppia-android/assets/76042077/98bd0994-5f4d-464f-b170-d21e167a9eaa)
|
Co-authored-by: Adhiambo Peres <59600948+adhiamboperes@users.noreply.github.com>
---
app/src/main/AndroidManifest.xml | 1 +
.../android/app/profile/AdminPinActivity.kt | 5 ++++
.../app/profile/AdminPinActivityPresenter.kt | 10 ++++---
.../main/res/layout/admin_pin_activity.xml | 26 ++++++++++++++++---
app/src/main/res/values/styles.xml | 4 +++
5 files changed, 39 insertions(+), 7 deletions(-)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a27551a2005..897fc034879 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -138,6 +138,7 @@
(activity, R.layout.admin_pin_activity)
+ activity.setSupportActionBar(binding.adminPinToolbar)
+ activity.supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ activity.supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_close_white_24dp)
+ activity.supportActionBar?.setHomeActionContentDescription(R.string.admin_auth_close)
+
binding.apply {
lifecycleOwner = activity
viewModel = adminViewModel
}
+ binding.adminPinToolbar.title = resourceHandler
+ .getStringInLocale(R.string.admin_auth_activity_add_profiles_title)
// [onTextChanged] is a extension function defined at [TextInputEditTextHelper]
binding.adminPinInputPinEditText.onTextChanged { pin ->
pin?.let {
diff --git a/app/src/main/res/layout/admin_pin_activity.xml b/app/src/main/res/layout/admin_pin_activity.xml
index e40ed050b9d..f22760b80dd 100644
--- a/app/src/main/res/layout/admin_pin_activity.xml
+++ b/app/src/main/res/layout/admin_pin_activity.xml
@@ -13,14 +13,32 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
+
+
+
+ android:scrollbars="none"
+ android:background="@color/component_color_shared_screen_secondary_background_color"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/admin_pin_toolbar"
+ >
@anim/slide_up
@anim/slide_down
+
+
From a1ba8bd3dab65d4213192918b8d4216330cb5a7e Mon Sep 17 00:00:00 2001
From: Vishwajith Shettigar
<76042077+Vishwajith-Shettigar@users.noreply.github.com>
Date: Thu, 7 Sep 2023 18:06:05 +0530
Subject: [PATCH 31/33] Fix #5142 Edit text foreground and background contrast
ratio issue in Light mode (#5144)
## Explanation
Fix #5142, changed text input default stroke color.
Added color resource file names component_color_text_layout.xml in order
to give stroke color
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
Berore fix
| Light mode| Dark mode|
|--------|--------|
|
![contarstlight_before](https://github.com/oppia/oppia-android/assets/76042077/0fcb4613-a7fb-4320-ae88-792704d61950)|![contastdark_before](https://github.com/oppia/oppia-android/assets/76042077/d36c6c13-654a-4603-8cef-80472dacf0d3)|
Afterfix
| Light mode| Dark mode|
|--------|--------|
|![newnew](https://github.com/oppia/oppia-android/assets/76042077/ef50252b-7c21-41ab-baee-2f244e87644b)|![contrastdark_after](https://github.com/oppia/oppia-android/assets/76042077/e4418fb6-9fb1-4c32-8c54-339235303abb)|
Screen recording after fix
Light mode
https://github.com/oppia/oppia-android/assets/76042077/943629a3-2b4a-4459-bbf9-0f0246860ffd
Dark mode
https://github.com/oppia/oppia-android/assets/76042077/c85256a0-5f99-4577-82e4-b95ce0d24027
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---
app/src/main/res/color/component_color_text_layout.xml | 5 +++++
app/src/main/res/values-night/color_palette.xml | 2 ++
app/src/main/res/values/color_palette.xml | 2 ++
app/src/main/res/values/styles.xml | 2 +-
4 files changed, 10 insertions(+), 1 deletion(-)
create mode 100644 app/src/main/res/color/component_color_text_layout.xml
diff --git a/app/src/main/res/color/component_color_text_layout.xml b/app/src/main/res/color/component_color_text_layout.xml
new file mode 100644
index 00000000000..9795672d6eb
--- /dev/null
+++ b/app/src/main/res/color/component_color_text_layout.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values-night/color_palette.xml b/app/src/main/res/values-night/color_palette.xml
index 7bb64f94cb7..299d498bfc6 100644
--- a/app/src/main/res/values-night/color_palette.xml
+++ b/app/src/main/res/values-night/color_palette.xml
@@ -225,4 +225,6 @@
@color/color_def_white@color/color_def_oppia_green@color/color_def_pale_green
+ @color/color_def_black_87
+ @color/color_def_white
diff --git a/app/src/main/res/values/color_palette.xml b/app/src/main/res/values/color_palette.xml
index 50e8f6caa30..13113daf937 100644
--- a/app/src/main/res/values/color_palette.xml
+++ b/app/src/main/res/values/color_palette.xml
@@ -265,4 +265,6 @@
@color/color_def_chooser_grey@color/color_def_persian_green@color/color_def_grey
+ @color/color_def_black_87
+ @color/color_def_black_87
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 2be703def67..149d87fd1d1 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -170,7 +170,7 @@
@dimen/text_input_layout_corner_radius@dimen/text_input_layout_corner_radius@dimen/text_input_layout_corner_radius
- @color/component_color_shared_text_input_layout_stroke_color
+ @color/component_color_text_layout@dimen/text_input_layout_stroke_widthtrue@color/component_color_shared_text_input_layout_error_color
From 5e74b842d565ace32bd6f839f4b835037cd3cd36 Mon Sep 17 00:00:00 2001
From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com>
Date: Tue, 12 Sep 2023 09:31:45 -0600
Subject: [PATCH 32/33] Fix #4608 : [Developer Video] How to use Talkback -
screen reader and what does its output mean? (#5148)
## Explanation
Fix #4608 : [Developer Video] How to use Talkback - screen reader and
what does its output mean?
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
---
wiki/Accessibility-A11y-Guide.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/wiki/Accessibility-A11y-Guide.md b/wiki/Accessibility-A11y-Guide.md
index 692560e18b2..54e713469f4 100644
--- a/wiki/Accessibility-A11y-Guide.md
+++ b/wiki/Accessibility-A11y-Guide.md
@@ -80,6 +80,8 @@ TalkBack is the Google **screen reader** included on Android devices. TalkBack g
5. Read all the instructions written on the screen as using Talkback requires specific steps.
6. Turn on **Use Service** -> **Allow**
+[Here](https://youtu.be/xpIM9xlowjs) is a video tutorial on "How to use Talkback and what does its output mean?".
+
### Useful Resources
* [Android A11Y Overview](https://support.google.com/accessibility/android/answer/6006564)
* [Using A11Y Menu](https://support.google.com/accessibility/android/answer/9078941)
@@ -89,6 +91,8 @@ TalkBack is the Google **screen reader** included on Android devices. TalkBack g
### Developer Videos
* [How to use Accessibility Scanner? - Tutorial](https://youtu.be/LF5AgGI5H3A)
* [Presentation Slides](https://docs.google.com/presentation/d/1PM_gs3TV2LVKFv6WuF9CUQHWbK7koepAxypzxeZTFzE/edit?usp=sharing)
+* [How to use Talkback and what does its output mean? - Tutorial](https://youtu.be/xpIM9xlowjs)
+ * [Presentation Slides](https://docs.google.com/presentation/d/17SeKJLKT-rUNa_Yupe97bMFSsjTNzp83jX-lZPKEtnQ/edit?usp=sharing)
## Using AccessibilityTestRule in Espresso Tests
[AccessibilityTestRule](https://github.com/oppia/oppia-android/blob/develop/testing/src/main/java/org/oppia/android/testing/AccessibilityTestRule.kt) is a JUnit rule to enable `AccessibilityChecks` in all Espresso Tests. This rule covers all errors shown by Accessibility Scanner and more but only for all those UI elements which are getting used in the test case.
From b643538dc4c0a38796675101da0d048472618a3e Mon Sep 17 00:00:00 2001
From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com>
Date: Tue, 12 Sep 2023 15:43:40 -0600
Subject: [PATCH 33/33] Fix #3122 : Dark theme make colors of the images
distorted (#5141)
## Explanation
Fix #3122 : Dark theme make the colors of the images distorted
Solution: Images change their color to white when Dark mode is turned
on. That's why we force disabling dark mode for Image Views prevents
android from force-inverting the colors in the images.
## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
## For UI-specific PRs only
| Before Fix | After Fix |
| ------ | ------ |
| | |
| Before Fix | After Fix |
| ------ | ------ |
| | |
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing
---
.../res/layout/profile_progress_recently_played_story_card.xml | 1 +
app/src/main/res/layout/promoted_story_card.xml | 1 +
app/src/main/res/layout/recently_played_story_card.xml | 1 +
3 files changed, 3 insertions(+)
diff --git a/app/src/main/res/layout/profile_progress_recently_played_story_card.xml b/app/src/main/res/layout/profile_progress_recently_played_story_card.xml
index 78a4576dbdd..5730c5b283e 100755
--- a/app/src/main/res/layout/profile_progress_recently_played_story_card.xml
+++ b/app/src/main/res/layout/profile_progress_recently_played_story_card.xml
@@ -37,6 +37,7 @@
android:importantForAccessibility="no"
app:entityId="@{viewModel.promotedStory.storyId}"
app:entityType="@{viewModel.entityType}"
+ android:forceDarkAllowed="false"
app:layout_constraintDimensionRatio="4:3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
diff --git a/app/src/main/res/layout/promoted_story_card.xml b/app/src/main/res/layout/promoted_story_card.xml
index 739f4bf4b57..91e711e0dae 100755
--- a/app/src/main/res/layout/promoted_story_card.xml
+++ b/app/src/main/res/layout/promoted_story_card.xml
@@ -35,6 +35,7 @@
android:importantForAccessibility="no"
app:entityId="@{viewModel.promotedStory.storyId}"
app:entityType="@{viewModel.entityType}"
+ android:forceDarkAllowed="false"
app:layout_constraintDimensionRatio="H, 16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
diff --git a/app/src/main/res/layout/recently_played_story_card.xml b/app/src/main/res/layout/recently_played_story_card.xml
index 9a85ae3f8c1..6dff08a05de 100755
--- a/app/src/main/res/layout/recently_played_story_card.xml
+++ b/app/src/main/res/layout/recently_played_story_card.xml
@@ -38,6 +38,7 @@
android:importantForAccessibility="no"
app:entityId="@{viewModel.promotedStory.storyId}"
app:entityType="@{viewModel.entityType}"
+ android:forceDarkAllowed="false"
app:layout_constraintDimensionRatio="4:3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"