diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 616781a..3032966 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -49,4 +49,5 @@ jobs: api-level: 33 arch: x86_64 emulator-options: "-no-window -no-snapshot -prop persist.sys.language=${{ steps.locale_props.outputs.language }} -prop persist.sys.country=${{ steps.locale_props.outputs.country }}" + device: "pixel_6" script: ./gradlew connectedAndroidTest diff --git a/README.md b/README.md index 9f4120b..7108ad2 100644 --- a/README.md +++ b/README.md @@ -44,4 +44,221 @@ To build the app and run all checks, including linting, execute the following co ./gradlew build ``` -This is the same command that is run in the GitHub Actions CI. Running this locally will help you find and fix issues before pushing your changes. \ No newline at end of file +This is the same command that is run in the GitHub Actions CI. Running this locally will help you find and fix issues before pushing your changes. + +## Running Instrumented Tests (Locally from the Terminal) + +Running the instrumented tests (`./gradlew connectedAndroidTest`) requires a connected Android device or an emulator. The following steps describe how to set up and run an Android emulator on Ubuntu entirely from the command line. + +### 1. KVM Hardware Acceleration Setup + +For better performance, the Android emulator should use KVM hardware acceleration. + +First, check if your CPU supports virtualization: +```bash +sudo apt-get update +sudo apt-get install -y cpu-checker +kvm-ok +``` +If the output includes `KVM acceleration can be used`, you are good to go. Otherwise, you may need to enable virtualization (e.g., VT-x or AMD-V) in your computer's BIOS/UEFI settings. + +Next, install required KVM packages and add your user to the `kvm` group. +```bash +sudo apt-get install -y qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils +sudo adduser $USER kvm +``` +You will need to **reboot your system** for the group change to take effect. + +### 2. Install Java (JDK) + +The Android command-line tools require a Java Development Kit. +```bash +sudo apt-get install -y openjdk-17-jdk +``` + +### 3. Install Android SDK Command-Line Tools + +1. **Download and set up the SDK:** + ```bash + # Download the latest command-line tools from Google + wget https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip + + # Create the necessary directory structure and unzip the tools + mkdir -p ~/android_sdk/cmdline-tools + unzip commandlinetools-linux-*.zip -d ~/android_sdk/cmdline-tools + mv ~/android_sdk/cmdline-tools/cmdline-tools ~/android_sdk/cmdline-tools/latest + rm commandlinetools-linux-*.zip + ``` + +2. **Set environment variables:** + Add the following to your `~/.bashrc` or `~/.zshrc` file: + ```bash + export ANDROID_HOME=$HOME/android_sdk + export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin + export PATH=$PATH:$ANDROID_HOME/platform-tools + export PATH=$PATH:$ANDROID_HOME/emulator + ``` + Then, source the file to apply the changes (e.g., `source ~/.bashrc`). + +### 4. Download System Image and Create AVD + +1. **Accept SDK licenses:** + ```bash + sdkmanager --licenses + ``` + Accept all licenses by typing `y` and pressing Enter. + +2. **Install the system image and other tools:** + The CI uses API level 33. We will install the corresponding system image. + ```bash + sdkmanager "platform-tools" "build-tools;33.0.2" "system-images;android-33;default;x86_64" "emulator" + ``` + +3. **Create the Android Virtual Device (AVD):** + ```bash + avdmanager create avd -n pixel_33 -k "system-images;android-33;default;x86_64" --device "pixel_6" + ``` + This creates an AVD named `pixel_33` using the `"pixel_6"` device profile, which simulates the screen size and characteristics of a Google Pixel 6 phone. The GitHub Actions CI is configured to use the same device profile to ensure that tests run in a consistent environment. + +### 5. Run the Tests + +1. **Start the emulator in the background:** + ```bash + emulator -avd pixel_33 -no-window & + ``` + Wait a minute or two for the emulator to fully boot up. + +2. **Run the connected tests:** + ```bash + ./gradlew connectedAndroidTest + ``` + The Gradle script will automatically detect the running emulator and execute the tests on it. + +### 6. Running Tests for a Different Locale + +To reproduce the CI environment for a specific locale, you need to start the emulator with the desired language settings. The CI runs tests against English, German, Spanish, and Arabic. + +First, make sure the emulator is not running (`adb emu kill`). + +Then, start the emulator with the desired locale. For example, to start the emulator with the German locale, run: + +```bash +emulator -avd pixel_33 -no-window -prop persist.sys.language=de -prop persist.sys.country=DE & +``` + +Wait for the emulator to boot up, and then run the tests: + +```bash +./gradlew connectedAndroidTest +``` + +### 7. Shut down the emulator when you're done: + ```bash + adb emu kill + ``` + +## Running the App on a Physical Device + +You can also install and run the app on a physical Android device using the command line. + +### 1. Enable USB Debugging on Your Device + +1. On your Android device, open **Settings**. +2. Go to **About phone**. +3. Tap **Build number** seven times to enable **Developer options**. +4. Go back to the main **Settings** menu, then go to **System** > **Developer options**. +5. Enable **USB debugging**. + +### 2. Connect Your Device via USB + +Connect your Android device to your computer using a USB cable. A prompt might appear on your device asking you to authorize the computer for debugging. Accept it. + +### 3. Build and Install the App + +1. **Navigate to the project directory:** + ```bash + cd /home/adrian/probability_puzzles + ``` + +2. **Build the debug APK:** + ```bash + ./gradlew assembleDebug + ``` + Note: Gradle uses an incremental build process. If you haven't made any code changes, Gradle may not rebuild the APK. To force a rebuild, you can run `./gradlew clean assembleDebug`. + +3. **Install the APK on your device:** + ```bash + adb install -r app/build/outputs/apk/debug/app-debug.apk + ``` + +The `-r` flag reinstalls the app, keeping its data. After the command completes, the app will be installed on your device, and you can run it by tapping its icon. + +## Running the App on a Physical Device (Wireless Debugging) + +You can also connect to your device wirelessly. This is especially useful if you have issues with USB drivers or cables. + +### 1. Enable Wireless Debugging + +1. On your Android device, open **Settings** > **System** > **Developer options**. +2. Enable **Wireless debugging**. +3. Make sure your device and your computer are on the same Wi-Fi network. + +### 2. Pair Your Device + +You only need to pair your device once. + +**Option A: Pairing with QR Code (Recommended)** + +1. In Android Studio, go to the **Device Manager** and select **Pair devices using Wi-Fi**. +2. A QR code will be displayed. +3. On your device, under **Wireless debugging**, select **Pair device with QR code** and scan the QR code on your computer. + +**Option B: Pairing with Command Line** + +1. On your device, under **Wireless debugging**, select **Pair device with pairing code**. +2. You will see a pairing code and an IP address with a port (e.g., `192.168.1.100:41234`). +3. On your computer, run the following command, replacing the IP, port, and pairing code with the ones from your device: + ```bash + adb pair 192.168.1.100:41234 123456 + ``` + +### 3. Connect to Your Device + +After pairing, you need to connect to your device. + +1. On your device, under **Wireless debugging**, you will see an IP address and port for the connection (e.g., `192.168.1.100:38765`). This port is different from the pairing port. +2. On your computer, run the following command, replacing the IP and port with the ones from your device: + ```bash + adb connect 192.168.1.100:38765 + ``` +3. You can verify the connection by running `adb devices`. You should see your device listed. + +### 4. Troubleshooting Wireless Debugging + +Wireless debugging can sometimes be tricky. Here are some common issues and how to solve them: + +* **`adb devices` shows `offline`:** + * On your device, go to **Settings** > **System** > **Developer options** and tap **Revoke USB debugging authorizations**. + * Toggle **Wireless debugging** off and on again. + * Reconnect to your device. + +* **`adb pair` fails with `protocol fault`:** + * This can be a network issue. Make sure you are not on a VPN. + * Check if your firewall is blocking the connection. + * Some routers have an "AP Isolation" feature that prevents devices from communicating. Make sure it's disabled. + * Restart the ADB server with `adb kill-server`. + +* **Build fails with `IOException: Unable to delete directory`:** + * This is likely a file ownership issue caused by running Android Studio with `sudo`. **Do not run Android Studio with `sudo`!** + * To fix this, you need to change the ownership of the project files back to your user. Run the following command, replacing `your_user` with your username: + ```bash + sudo chown -R your_user:your_user /path/to/your/project + ``` + * You might also need to stop the Gradle daemon with `./gradlew --stop`. + +* **Installation fails with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`:** + * This means the app is already installed with a different signature (e.g., from the Play Store). + * Uninstall the app from your device and try again. You can do this from the command line: + ```bash + adb uninstall atorch.statspuzzles + ``` diff --git a/app/build.gradle b/app/build.gradle index 360e7c4..0b9c8b8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -38,7 +38,8 @@ dependencies { implementation 'androidx.viewpager2:viewpager2:1.1.0' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + androidTestImplementation 'androidx.test.espresso:espresso-intents:3.6.1' implementation fileTree(dir: 'libs', include: ['*.aar', '*.jar'], exclude: []) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10" } diff --git a/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java b/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java new file mode 100644 index 0000000..5b01474 --- /dev/null +++ b/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java @@ -0,0 +1,58 @@ +package atorch.statspuzzles; + +import android.content.Context; +import android.content.Intent; +import androidx.test.core.app.ActivityScenario; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.espresso.Espresso; +import androidx.test.espresso.intent.Intents; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.scrollTo; +import static androidx.test.espresso.intent.Intents.intended; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasData; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static org.hamcrest.CoreMatchers.allOf; + +@RunWith(AndroidJUnit4.class) +public class SolvePuzzleTest { + + @Before + public void setUp() { + Intents.init(); + } + + @After + public void tearDown() { + Intents.release(); + } + + @Test + public void geminiButton_launchesPlayStore() { + Context context = ApplicationProvider.getApplicationContext(); + Intent intent = new Intent(context, SolvePuzzle.class); + intent.putExtra(SolvePuzzle.LEVEL, 0); + ActivityScenario.launch(intent); + + // On smaller screens, the button might be off-screen, so we scroll to it first. + // isDisplayed() is still needed to ensure we click the button in the currently visible + // fragment, as multiple fragments can exist in a ViewPager2. + Espresso.onView(allOf(withId(R.id.button_gemini_hint), isDisplayed())).perform(scrollTo(), click()); + + // This tests the logic in SolvePuzzle's showGeminiHint, which tries to open the Gemini app + // directly if it's installed, and otherwise falls back to the Play Store. + // The test framework doesn't have Gemini installed, so it should always go to the Play Store. + intended(allOf( + hasAction(Intent.ACTION_VIEW), + hasData("market://details?id=com.google.android.apps.bard") + )); + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c7495e6..08ed1ef 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + showHint()); + Button geminiHintButton = rootView.findViewById(R.id.button_gemini_hint); + geminiHintButton.setOnClickListener(v -> onGeminiHint()); + Button submitButton = rootView.findViewById(R.id.submit_answer); submitButton.setOnClickListener(this::onSubmit); String answer = res.getAnswer(level, puzzleIndex); EditText user_answer = rootView.findViewById(R.id.user_answer); - SharedPreferences preferences = getActivity().getSharedPreferences("atorch.statspuzzles.data", Context.MODE_PRIVATE); + SharedPreferences preferences = activity.getSharedPreferences("atorch.statspuzzles.data", Context.MODE_PRIVATE); String key = level + "_" + puzzleIndex; boolean already_solved_this_puzzle = preferences.getBoolean(key, false); if (already_solved_this_puzzle) { @@ -297,7 +306,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa } // Add image below puzzle statement - String packageName = getActivity().getPackageName(); + String packageName = activity.getPackageName(); String image = res.getImage(level, puzzleIndex); if (!image.isEmpty()) { ImageView imageView = rootView.findViewById(R.id.puzzleImage); @@ -312,7 +321,7 @@ private void showHint() { SpannableString hintSpannable = new SpannableString(hint); // msg should have url to enable clicking Linkify.addLinks(hintSpannable, Linkify.ALL); - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()); builder.setMessage(hintSpannable); builder.setCancelable(true); builder.setPositiveButton(R.string.button_back_to_puzzle, @@ -325,6 +334,37 @@ private void showHint() { ((TextView) alert.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance()); } + private void onGeminiHint() { + final FragmentActivity activity = requireActivity(); + String puzzleText = res.getPuzzle(level, puzzleIndex); + String hint = res.getHint(level, puzzleIndex); + String prompt = getString(R.string.gemini_prompt_template) + " " + puzzleText + + "\n\n" + getString(R.string.gemini_expand_hint) + " " + hint; + + // Copy to clipboard + ClipboardManager clipboard = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("puzzle", prompt); + clipboard.setPrimaryClip(clip); + Toast.makeText(activity, R.string.puzzle_copied_to_clipboard, Toast.LENGTH_SHORT).show(); + + // The package name for the Google Gemini app is com.google.android.apps.bard. + String geminiPackageName = "com.google.android.apps.bard"; + PackageManager pm = activity.getPackageManager(); + Intent intent = pm.getLaunchIntentForPackage(geminiPackageName); + + if (intent != null) { + // The Gemini app is installed. Launch it. + activity.startActivity(intent); + } else { + // If the Gemini app is not installed, open the Play Store. + try { + activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=" + geminiPackageName))); + } catch (android.content.ActivityNotFoundException anfe) { + activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=" + geminiPackageName))); + } + } + } + private void onSubmit(View view) { ImageView checkMark = getView().findViewById(R.id.check_mark); checkMark.setVisibility(View.INVISIBLE); @@ -359,7 +399,7 @@ private void onSubmit(View view) { } public void openTroubleParsingDialog(View view) { - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()); builder.setMessage(getString(R.string.trouble_parsing_answer)); builder.setCancelable(true); builder.setPositiveButton(R.string.okay_button, @@ -370,9 +410,10 @@ public void openTroubleParsingDialog(View view) { } public void openCongratulationsAlert(View view) { + final FragmentActivity activity = requireActivity(); ImageView checkmark = getView().findViewById(R.id.check_mark); checkmark.setVisibility(View.VISIBLE); - SharedPreferences preferences = getActivity().getSharedPreferences("atorch.statspuzzles.data", Context.MODE_PRIVATE); + SharedPreferences preferences = activity.getSharedPreferences("atorch.statspuzzles.data", Context.MODE_PRIVATE); SharedPreferences.Editor editor = preferences.edit(); String key = level + "_" + puzzleIndex; boolean already_solved_this_puzzle = preferences.getBoolean(key, false); @@ -395,7 +436,7 @@ public void openCongratulationsAlert(View view) { editor.putBoolean(key, true); editor.commit(); - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + AlertDialog.Builder builder = new AlertDialog.Builder(activity); ViewPager2 viewPager = view.getRootView().findViewById(R.id.pager); int nPuzzles = res.getPuzzleCount(level); @@ -415,7 +456,7 @@ public void openCongratulationsAlert(View view) { ); builder.setNegativeButton(R.string.main_menu_button, (dialog, id) -> { - Intent intent = new Intent(getActivity(), PuzzleSelection.class); + Intent intent = new Intent(activity, PuzzleSelection.class); startActivity(intent); } ); @@ -428,7 +469,7 @@ public void openCongratulationsAlert(View view) { builder.setCancelable(true); builder.setPositiveButton(R.string.main_menu_button, (dialog, id) -> { - Intent intent = new Intent(getActivity(), PuzzleSelection.class); + Intent intent = new Intent(activity, PuzzleSelection.class); startActivity(intent); } ); @@ -442,12 +483,12 @@ public void openCongratulationsAlert(View view) { } public void openIncorrectAnswerToast() { - Context context = getActivity(); + Context context = requireActivity(); Toast.makeText(context, res.getRandomIncorrect(), Toast.LENGTH_LONG).show(); } public void openAccuracyAlert(View view) { - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()); builder.setMessage(getString(R.string.accuracy)); builder.setCancelable(true); builder.setPositiveButton(R.string.ok_button, @@ -457,16 +498,10 @@ public void openAccuracyAlert(View view) { alert.show(); } - @Override - public void onPause() { - hideSoftKeyboard(); - super.onPause(); - } - public void hideSoftKeyboard() { View editBox = getView().findViewById(R.id.user_answer); editBox.clearFocus(); - InputMethodManager IMM = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + InputMethodManager IMM = (InputMethodManager) requireContext().getSystemService(Context.INPUT_METHOD_SERVICE); IMM.hideSoftInputFromWindow(editBox.getWindowToken(), 0); } diff --git a/app/src/main/res/layout/fragment_solve_puzzle.xml b/app/src/main/res/layout/fragment_solve_puzzle.xml index 13066ce..1a60d82 100644 --- a/app/src/main/res/layout/fragment_solve_puzzle.xml +++ b/app/src/main/res/layout/fragment_solve_puzzle.xml @@ -1,7 +1,8 @@ + android:layout_height="match_parent" + tools:context="atorch.statspuzzles.PuzzleSelection" > + android:paddingTop="@dimen/activity_vertical_margin_half" > +