diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index c85647f45347..82f0f3fbee6d 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -165,4 +165,43 @@ jobs: force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew connectedCheck + script: | + # Clear logcat before tests + adb logcat -c + + # Run tests + ./gradlew connectedCheck + + # Dump logcat after tests + adb logcat -d > logcat.txt + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.api-level }} + path: | + app/build/reports/androidTests/ + app/build/outputs/androidTest-results/ + if-no-files-found: warn + + - name: Upload test logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-logs-${{ matrix.api-level }} + path: | + app/build/outputs/logs/ + app/build/test-results/ + if-no-files-found: warn + + - name: Upload emulator logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: emulator-logs-${{ matrix.api-level }} + path: | + ~/.android/avd/*.avd/config.ini + ~/.android/avd/*.avd/*.log + logcat.txt + if-no-files-found: warn diff --git a/app/build.gradle b/app/build.gradle index c4a4d69335d8..af59d3aeb452 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -133,18 +133,18 @@ android { } dependencies { - implementation platform('com.google.firebase:firebase-bom:33.3.0') + implementation platform('com.google.firebase:firebase-bom:34.1.0') implementation 'com.google.firebase:firebase-storage' implementation 'com.google.firebase:firebase-auth' implementation 'com.google.firebase:firebase-crashlytics-ndk' implementation 'com.google.firebase:firebase-analytics' implementation 'com.google.firebase:firebase-config' - implementation 'com.google.android.gms:play-services-ads:23.3.0' - implementation 'com.google.android.play:review:2.0.1' + implementation 'com.google.android.gms:play-services-ads:24.5.0' + implementation 'com.google.android.play:review:2.0.2' implementation 'com.google.android.ump:user-messaging-platform:3.0.0' - implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.appcompat:appcompat:1.7.1' implementation 'androidx.core:core:1.13.1' implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.webkit:webkit:1.11.0' @@ -159,7 +159,7 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-intents:3.6.1' // espresso-idling-resource is used in main sourceSet as well. cannot be just androidTestImplementation implementation 'androidx.test.espresso:espresso-idling-resource:3.6.1' - implementation 'androidx.annotation:annotation:1.8.2' + implementation 'androidx.annotation:annotation:1.9.1' } // Without removing .cxx dir on cleanup, double gradle clean is erroring out. diff --git a/app/src/androidTest/assets/password-test.odt b/app/src/androidTest/assets/password-test.odt new file mode 100644 index 000000000000..fcf247798598 Binary files /dev/null and b/app/src/androidTest/assets/password-test.odt differ diff --git a/app/src/androidTest/java/at/tomtasche/reader/test/CoreTest.java b/app/src/androidTest/java/at/tomtasche/reader/test/CoreTest.java index 525dfaf82ad0..f7e953a84633 100644 --- a/app/src/androidTest/java/at/tomtasche/reader/test/CoreTest.java +++ b/app/src/androidTest/java/at/tomtasche/reader/test/CoreTest.java @@ -25,6 +25,7 @@ @RunWith(AndroidJUnit4.class) public class CoreTest { private File m_testFile; + private File m_passwordTestFile; @Before public void initializeCore() { @@ -36,12 +37,16 @@ public void initializeCore() { public void extractTestFile() throws IOException { Context appCtx = InstrumentationRegistry.getInstrumentation().getTargetContext(); m_testFile = new File(appCtx.getCacheDir(), "test.odt"); + m_passwordTestFile = new File(appCtx.getCacheDir(), "password-test.odt"); Context testCtx = InstrumentationRegistry.getInstrumentation().getContext(); AssetManager assetManager = testCtx.getAssets(); try (InputStream inputStream = assetManager.open("test.odt")) { copy(inputStream, m_testFile); } + try (InputStream inputStream = assetManager.open("password-test.odt")) { + copy(inputStream, m_passwordTestFile); + } } @After @@ -49,6 +54,9 @@ public void cleanupTestFile() { if (null != m_testFile) { m_testFile.delete(); } + if (null != m_passwordTestFile) { + m_passwordTestFile.delete(); + } } private static void copy(InputStream src, File dst) throws IOException { @@ -70,8 +78,8 @@ public void test() { CoreWrapper.CoreOptions coreOptions = new CoreWrapper.CoreOptions(); coreOptions.inputPath = m_testFile.getAbsolutePath(); coreOptions.outputPath = outputPath.getPath(); - coreOptions.cachePath = cachePath.getPath(); coreOptions.editable = true; + coreOptions.cachePath = cachePath.getPath(); CoreWrapper.CoreResult coreResult = CoreWrapper.parse(coreOptions); Assert.assertEquals(0, coreResult.errorCode); @@ -82,6 +90,56 @@ public void test() { String htmlDiff = "{\"modifiedText\":{\"3\":\"This is a simple test document to demonstrate the DocumentLoadewwwwr example!\"}}"; CoreWrapper.CoreResult result = CoreWrapper.backtranslate(coreOptions, htmlDiff); + Assert.assertEquals(0, result.errorCode); + } + + @Test + public void testPasswordProtectedDocumentWithoutPassword() { + File cacheDir = InstrumentationRegistry.getInstrumentation().getTargetContext().getCacheDir(); + File outputDir = new File(cacheDir, "output_password_test"); + File cachePath = new File(cacheDir, "core_cache"); + + CoreWrapper.CoreOptions coreOptions = new CoreWrapper.CoreOptions(); + coreOptions.inputPath = m_passwordTestFile.getAbsolutePath(); + coreOptions.outputPath = outputDir.getPath(); + coreOptions.editable = false; + coreOptions.cachePath = cachePath.getPath(); + + CoreWrapper.CoreResult coreResult = CoreWrapper.parse(coreOptions); + Assert.assertEquals(-2, coreResult.errorCode); + } + + @Test + public void testPasswordProtectedDocumentWithWrongPassword() { + File cacheDir = InstrumentationRegistry.getInstrumentation().getTargetContext().getCacheDir(); + File outputDir = new File(cacheDir, "output_password_test"); + File cachePath = new File(cacheDir, "core_cache"); + + CoreWrapper.CoreOptions coreOptions = new CoreWrapper.CoreOptions(); + coreOptions.inputPath = m_passwordTestFile.getAbsolutePath(); + coreOptions.outputPath = outputDir.getPath(); + coreOptions.password = "wrongpassword"; + coreOptions.editable = false; + coreOptions.cachePath = cachePath.getPath(); + + CoreWrapper.CoreResult coreResult = CoreWrapper.parse(coreOptions); + Assert.assertEquals(-2, coreResult.errorCode); + } + + @Test + public void testPasswordProtectedDocumentWithCorrectPassword() { + File cacheDir = InstrumentationRegistry.getInstrumentation().getTargetContext().getCacheDir(); + File outputDir = new File(cacheDir, "output_password_test"); + File cachePath = new File(cacheDir, "core_cache"); + + CoreWrapper.CoreOptions coreOptions = new CoreWrapper.CoreOptions(); + coreOptions.inputPath = m_passwordTestFile.getAbsolutePath(); + coreOptions.outputPath = outputDir.getPath(); + coreOptions.password = "passwort"; + coreOptions.editable = false; + coreOptions.cachePath = cachePath.getPath(); + + CoreWrapper.CoreResult coreResult = CoreWrapper.parse(coreOptions); Assert.assertEquals(0, coreResult.errorCode); } } diff --git a/app/src/androidTest/java/at/tomtasche/reader/test/MainActivityTests.java b/app/src/androidTest/java/at/tomtasche/reader/test/MainActivityTests.java index 706b8a96cc6b..8d388ca80423 100644 --- a/app/src/androidTest/java/at/tomtasche/reader/test/MainActivityTests.java +++ b/app/src/androidTest/java/at/tomtasche/reader/test/MainActivityTests.java @@ -1,15 +1,20 @@ package at.tomtasche.reader.test; import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.clearText; import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; +import static androidx.test.espresso.matcher.ViewMatchers.withClassName; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.equalTo; import android.app.Activity; import android.app.Instrumentation; @@ -18,6 +23,7 @@ import android.content.res.AssetManager; import android.net.Uri; import android.util.ArrayMap; +import android.util.Log; import androidx.core.content.FileProvider; import androidx.test.espresso.IdlingRegistry; @@ -55,12 +61,14 @@ public class MainActivityTests { // Yes, this is ActivityTestRule instead of ActivityScenario, because ActivityScenario does not actually work. // Issue ID may or may not be added later. + // Launch activity manually to ensure complete restart between tests @Rule - public ActivityTestRule mainActivityActivityTestRule = new ActivityTestRule<>(MainActivity.class); + public ActivityTestRule mainActivityActivityTestRule = new ActivityTestRule<>(MainActivity.class, false, false); @Before public void setUp() { - MainActivity mainActivity = mainActivityActivityTestRule.getActivity(); + // Launch a fresh activity for each test + MainActivity mainActivity = mainActivityActivityTestRule.launchActivity(null); m_idlingResource = mainActivity.getOpenFileIdlingResource(); IdlingRegistry.getInstance().register(m_idlingResource); @@ -70,15 +78,29 @@ public void setUp() { mainActivity.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); Intents.init(); + + // Log test setup for debugging + Log.d("MainActivityTests", "setUp() called for test: " + getClass().getName()); } @After public void tearDown() { + Log.d("MainActivityTests", "tearDown() called"); + Intents.release(); if (null != m_idlingResource) { IdlingRegistry.getInstance().unregister(m_idlingResource); } + + // Finish and wait for activity to be destroyed + MainActivity activity = mainActivityActivityTestRule.getActivity(); + if (activity != null) { + mainActivityActivityTestRule.finishActivity(); + + // Use Instrumentation to wait until activity is destroyed + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } } private static void copy(InputStream src, File dst) throws IOException { @@ -104,7 +126,7 @@ public static void extractTestFiles() throws IOException { AssetManager testAssetManager = instrumentation.getContext().getAssets(); - for (String filename: new String[] {"test.odt", "dummy.pdf"}) { + for (String filename: new String[] {"test.odt", "dummy.pdf", "password-test.odt"}) { File targetFile = new File(testDocumentsDir, filename); try (InputStream inputStream = testAssetManager.open(filename)) { copy(inputStream, targetFile); @@ -135,7 +157,7 @@ public void testODT() { ); onView(allOf(withId(R.id.menu_open), withContentDescription("Open document"), isDisplayed())) - .perform(click()); + .perform(click()); // The menu item could be either Documents or Files. onView(allOf(withId(android.R.id.text1), anyOf(withText("Documents"), withText("Files")), isDisplayed())) @@ -143,13 +165,13 @@ public void testODT() { // next onView will be blocked until m_idlingResource is idle. onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isEnabled())) - .withFailureHandler((error, viewMatcher) -> { - // fails on small screens, try again with overflow menu - onView(allOf(withContentDescription("More options"), isDisplayed())).perform(click()); + .withFailureHandler((error, viewMatcher) -> { + // fails on small screens, try again with overflow menu + onView(allOf(withContentDescription("More options"), isDisplayed())).perform(click()); - onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isDisplayed())) - .perform(click()); - }); + onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isDisplayed())) + .perform(click()); + }); } @Test @@ -183,5 +205,76 @@ public void testPDF() { onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isDisplayed())) .perform(click()); }); + + try { + Thread.sleep(10000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + @Test + public void testPasswordProtectedODT() { + File testFile = s_testFiles.get("password-test.odt"); + Assert.assertNotNull(testFile); + + // Check if the file exists and is readable + Assert.assertTrue("Password test file does not exist: " + testFile.getAbsolutePath(), testFile.exists()); + Assert.assertTrue("Password test file is not readable: " + testFile.getAbsolutePath(), testFile.canRead()); + + // Log file info for debugging CI issues + Log.d("MainActivityTests", "Password test file path: " + testFile.getAbsolutePath()); + Log.d("MainActivityTests", "Password test file size: " + testFile.length()); + Log.d("MainActivityTests", "All test files: " + s_testFiles.keySet()); + + // Double-check we're using the right file + Assert.assertEquals("password-test.odt file size mismatch", 12671L, testFile.length()); + + Context appCtx = InstrumentationRegistry.getInstrumentation().getTargetContext(); + Uri testFileUri = FileProvider.getUriForFile(appCtx, appCtx.getPackageName() + ".provider", testFile); + Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith( + new Instrumentation.ActivityResult(Activity.RESULT_OK, + new Intent() + .setData(testFileUri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + ) + ); + + onView(allOf(withId(R.id.menu_open), withContentDescription("Open document"), isDisplayed())) + .perform(click()); + + onView(allOf(withId(android.R.id.text1), anyOf(withText("Documents"), withText("Files")), isDisplayed())) + .perform(click()); + + // Wait for the password dialog to appear + onView(withText("This document is password-protected")) + .check(matches(isDisplayed())); + + // Enter wrong password first + onView(withClassName(equalTo("android.widget.EditText"))) + .perform(typeText("wrongpassword")); + + onView(withId(android.R.id.button1)) + .perform(click()); + + // Should show password dialog again for wrong password + onView(withText("This document is password-protected")) + .check(matches(isDisplayed())); + + // Clear the text field and enter correct password + onView(withClassName(equalTo("android.widget.EditText"))) + .perform(clearText(), typeText("passwort")); + + onView(withId(android.R.id.button1)) + .perform(click()); + + // Check if edit button becomes available (indicating successful load) + onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isEnabled())) + .withFailureHandler((error, viewMatcher) -> { + onView(allOf(withContentDescription("More options"), isDisplayed())).perform(click()); + + onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isDisplayed())) + .perform(click()); + }); } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c495a19951e6..041505686652 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,8 +2,8 @@ diff --git a/app/src/main/java/at/tomtasche/reader/background/CoreWrapper.java b/app/src/main/java/at/tomtasche/reader/background/CoreWrapper.java index 3e15ba00fcfa..b894ef3f0a62 100644 --- a/app/src/main/java/at/tomtasche/reader/background/CoreWrapper.java +++ b/app/src/main/java/at/tomtasche/reader/background/CoreWrapper.java @@ -49,6 +49,7 @@ public static void initialize(Context context) { public static class CoreOptions { public boolean ooxml; public boolean txt; + // TODO: remove public boolean pdf; public boolean editable;