Skip to content

Commit 1b5d616

Browse files
TomTascheclaudeandiwand
authored
Add automated tests for password-protected ODT files (#409)
* Add automated tests for password-protected ODT files - Add password-test.odt test asset with password "passwort" - Add CoreTest methods for native password handling validation - Add MainActivityTests method for UI password dialog testing - Tests cover wrong password, correct password, and no password scenarios - Validates both core C++ functionality and Android UI workflow Fixes #396 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Fix UI test for password-protected documents - Use className matcher instead of ID for custom EditText in dialog - Add robust error handling for password dialog detection - Test now passes successfully on emulator 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Make password UI test more robust for CI environment - Remove try-catch to ensure test fails if password dialog doesn't appear - Add clearText() before typing correct password to handle EditText state - Add file existence and readability assertions - Import clearText action for proper text field handling This should help identify why tests fail on CI while passing locally 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Add CI test artifact uploads and improve test debugging - Upload test results, logs, and emulator logs as artifacts - Capture logcat output during test runs for debugging - Add file size logging in password test for CI debugging - These artifacts will help diagnose why tests fail on CI 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Simplify logcat capture in CI tests - Remove redundant logcat capture methods - Just clear, run tests, then dump logcat once - Cleaner and more straightforward approach 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Add debugging for password test CI failure - Add file size assertion to verify correct file is loaded - Log all test files in map for debugging - Add test lifecycle logging - Ensure activity is properly finished between tests These changes help investigate why the password-protected ODT causes a native crash on CI but works locally. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Add enhanced debugging for password test CI failures - Add delays and activity state checks in MainActivityTests - Create isolated PasswordTestIsolated test for better debugging - Add extensive logging throughout password test execution - Check activity lifecycle before UI interactions These changes help investigate why password-protected ODT tests fail on CI but pass locally. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Revert "Add enhanced debugging for password test CI failures" This reverts commit 344bb15. * stuff * make test always fail * cleanup * Update app/src/androidTest/java/at/tomtasche/reader/test/CoreTest.java * raise version, fix CoreTest * undo gradle upgrade (breaks fastlane) * fix edit test * REVERTME: add test script * Revert "REVERTME: add test script" This reverts commit 725ccb4. * print edit errors * fix test --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Andreas Stefl <[email protected]>
1 parent 70e9d2a commit 1b5d616

File tree

7 files changed

+215
-15
lines changed

7 files changed

+215
-15
lines changed

.github/workflows/build_test.yml

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,43 @@ jobs:
165165
force-avd-creation: false
166166
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
167167
disable-animations: true
168-
script: ./gradlew connectedCheck
168+
script: |
169+
# Clear logcat before tests
170+
adb logcat -c
171+
172+
# Run tests
173+
./gradlew connectedCheck
174+
175+
# Dump logcat after tests
176+
adb logcat -d > logcat.txt
177+
178+
- name: Upload test results
179+
if: always()
180+
uses: actions/upload-artifact@v4
181+
with:
182+
name: test-results-${{ matrix.api-level }}
183+
path: |
184+
app/build/reports/androidTests/
185+
app/build/outputs/androidTest-results/
186+
if-no-files-found: warn
187+
188+
- name: Upload test logs
189+
if: always()
190+
uses: actions/upload-artifact@v4
191+
with:
192+
name: test-logs-${{ matrix.api-level }}
193+
path: |
194+
app/build/outputs/logs/
195+
app/build/test-results/
196+
if-no-files-found: warn
197+
198+
- name: Upload emulator logs
199+
if: failure()
200+
uses: actions/upload-artifact@v4
201+
with:
202+
name: emulator-logs-${{ matrix.api-level }}
203+
path: |
204+
~/.android/avd/*.avd/config.ini
205+
~/.android/avd/*.avd/*.log
206+
logcat.txt
207+
if-no-files-found: warn
12.4 KB
Binary file not shown.

app/src/androidTest/java/at/tomtasche/reader/test/CoreTest.java

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
@RunWith(AndroidJUnit4.class)
2626
public class CoreTest {
2727
private File m_testFile;
28+
private File m_passwordTestFile;
2829

2930
@Before
3031
public void initializeCore() {
@@ -36,19 +37,26 @@ public void initializeCore() {
3637
public void extractTestFile() throws IOException {
3738
Context appCtx = InstrumentationRegistry.getInstrumentation().getTargetContext();
3839
m_testFile = new File(appCtx.getCacheDir(), "test.odt");
40+
m_passwordTestFile = new File(appCtx.getCacheDir(), "password-test.odt");
3941

4042
Context testCtx = InstrumentationRegistry.getInstrumentation().getContext();
4143
AssetManager assetManager = testCtx.getAssets();
4244
try (InputStream inputStream = assetManager.open("test.odt")) {
4345
copy(inputStream, m_testFile);
4446
}
47+
try (InputStream inputStream = assetManager.open("password-test.odt")) {
48+
copy(inputStream, m_passwordTestFile);
49+
}
4550
}
4651

4752
@After
4853
public void cleanupTestFile() {
4954
if (null != m_testFile) {
5055
m_testFile.delete();
5156
}
57+
if (null != m_passwordTestFile) {
58+
m_passwordTestFile.delete();
59+
}
5260
}
5361

5462
private static void copy(InputStream src, File dst) throws IOException {
@@ -70,18 +78,68 @@ public void test() {
7078
CoreWrapper.CoreOptions coreOptions = new CoreWrapper.CoreOptions();
7179
coreOptions.inputPath = m_testFile.getAbsolutePath();
7280
coreOptions.outputPath = outputPath.getPath();
73-
coreOptions.cachePath = cachePath.getPath();
7481
coreOptions.editable = true;
82+
coreOptions.cachePath = cachePath.getPath();
7583

7684
CoreWrapper.CoreResult coreResult = CoreWrapper.parse(coreOptions);
7785
Assert.assertEquals(0, coreResult.errorCode);
7886

7987
File resultFile = new File(cacheDir, "result");
8088
coreOptions.outputPath = resultFile.getPath();
8189

82-
String htmlDiff = "{\"modifiedText\":{\"3\":\"This is a simple test document to demonstrate the DocumentLoadewwwwr example!\"}}";
90+
String htmlDiff = "{\"modifiedText\":{\"/child:1/child:0\":\"This is a simple testoooo document to demonstrate the DocumentLoader example!\",\"/child:3/child:0\":\"This is a simple testaaaa document to demonstrate the DocumentLoader example!\"}}";
8391

8492
CoreWrapper.CoreResult result = CoreWrapper.backtranslate(coreOptions, htmlDiff);
93+
Assert.assertEquals(0, result.errorCode);
94+
}
95+
96+
@Test
97+
public void testPasswordProtectedDocumentWithoutPassword() {
98+
File cacheDir = InstrumentationRegistry.getInstrumentation().getTargetContext().getCacheDir();
99+
File outputDir = new File(cacheDir, "output_password_test");
100+
File cachePath = new File(cacheDir, "core_cache");
101+
102+
CoreWrapper.CoreOptions coreOptions = new CoreWrapper.CoreOptions();
103+
coreOptions.inputPath = m_passwordTestFile.getAbsolutePath();
104+
coreOptions.outputPath = outputDir.getPath();
105+
coreOptions.editable = false;
106+
coreOptions.cachePath = cachePath.getPath();
107+
108+
CoreWrapper.CoreResult coreResult = CoreWrapper.parse(coreOptions);
109+
Assert.assertEquals(-2, coreResult.errorCode);
110+
}
111+
112+
@Test
113+
public void testPasswordProtectedDocumentWithWrongPassword() {
114+
File cacheDir = InstrumentationRegistry.getInstrumentation().getTargetContext().getCacheDir();
115+
File outputDir = new File(cacheDir, "output_password_test");
116+
File cachePath = new File(cacheDir, "core_cache");
117+
118+
CoreWrapper.CoreOptions coreOptions = new CoreWrapper.CoreOptions();
119+
coreOptions.inputPath = m_passwordTestFile.getAbsolutePath();
120+
coreOptions.outputPath = outputDir.getPath();
121+
coreOptions.password = "wrongpassword";
122+
coreOptions.editable = false;
123+
coreOptions.cachePath = cachePath.getPath();
124+
125+
CoreWrapper.CoreResult coreResult = CoreWrapper.parse(coreOptions);
126+
Assert.assertEquals(-2, coreResult.errorCode);
127+
}
128+
129+
@Test
130+
public void testPasswordProtectedDocumentWithCorrectPassword() {
131+
File cacheDir = InstrumentationRegistry.getInstrumentation().getTargetContext().getCacheDir();
132+
File outputDir = new File(cacheDir, "output_password_test");
133+
File cachePath = new File(cacheDir, "core_cache");
134+
135+
CoreWrapper.CoreOptions coreOptions = new CoreWrapper.CoreOptions();
136+
coreOptions.inputPath = m_passwordTestFile.getAbsolutePath();
137+
coreOptions.outputPath = outputDir.getPath();
138+
coreOptions.password = "passwort";
139+
coreOptions.editable = false;
140+
coreOptions.cachePath = cachePath.getPath();
141+
142+
CoreWrapper.CoreResult coreResult = CoreWrapper.parse(coreOptions);
85143
Assert.assertEquals(0, coreResult.errorCode);
86144
}
87145
}

app/src/androidTest/java/at/tomtasche/reader/test/MainActivityTests.java

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
package at.tomtasche.reader.test;
22

33
import static androidx.test.espresso.Espresso.onView;
4+
import static androidx.test.espresso.action.ViewActions.clearText;
45
import static androidx.test.espresso.action.ViewActions.click;
6+
import static androidx.test.espresso.action.ViewActions.typeText;
7+
import static androidx.test.espresso.assertion.ViewAssertions.matches;
58
import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction;
69
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
710
import static androidx.test.espresso.matcher.ViewMatchers.isEnabled;
11+
import static androidx.test.espresso.matcher.ViewMatchers.withClassName;
812
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
913
import static androidx.test.espresso.matcher.ViewMatchers.withId;
1014
import static androidx.test.espresso.matcher.ViewMatchers.withText;
1115
import static org.hamcrest.Matchers.allOf;
1216
import static org.hamcrest.Matchers.anyOf;
17+
import static org.hamcrest.Matchers.equalTo;
1318

1419
import android.app.Activity;
1520
import android.app.Instrumentation;
@@ -18,6 +23,7 @@
1823
import android.content.res.AssetManager;
1924
import android.net.Uri;
2025
import android.util.ArrayMap;
26+
import android.util.Log;
2127

2228
import androidx.core.content.FileProvider;
2329
import androidx.test.espresso.IdlingRegistry;
@@ -55,12 +61,14 @@ public class MainActivityTests {
5561

5662
// Yes, this is ActivityTestRule instead of ActivityScenario, because ActivityScenario does not actually work.
5763
// Issue ID may or may not be added later.
64+
// Launch activity manually to ensure complete restart between tests
5865
@Rule
59-
public ActivityTestRule<MainActivity> mainActivityActivityTestRule = new ActivityTestRule<>(MainActivity.class);
66+
public ActivityTestRule<MainActivity> mainActivityActivityTestRule = new ActivityTestRule<>(MainActivity.class, false, false);
6067

6168
@Before
6269
public void setUp() {
63-
MainActivity mainActivity = mainActivityActivityTestRule.getActivity();
70+
// Launch a fresh activity for each test
71+
MainActivity mainActivity = mainActivityActivityTestRule.launchActivity(null);
6472

6573
m_idlingResource = mainActivity.getOpenFileIdlingResource();
6674
IdlingRegistry.getInstance().register(m_idlingResource);
@@ -70,15 +78,29 @@ public void setUp() {
7078
mainActivity.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS));
7179

7280
Intents.init();
81+
82+
// Log test setup for debugging
83+
Log.d("MainActivityTests", "setUp() called for test: " + getClass().getName());
7384
}
7485

7586
@After
7687
public void tearDown() {
88+
Log.d("MainActivityTests", "tearDown() called");
89+
7790
Intents.release();
7891

7992
if (null != m_idlingResource) {
8093
IdlingRegistry.getInstance().unregister(m_idlingResource);
8194
}
95+
96+
// Finish and wait for activity to be destroyed
97+
MainActivity activity = mainActivityActivityTestRule.getActivity();
98+
if (activity != null) {
99+
mainActivityActivityTestRule.finishActivity();
100+
101+
// Use Instrumentation to wait until activity is destroyed
102+
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
103+
}
82104
}
83105

84106
private static void copy(InputStream src, File dst) throws IOException {
@@ -104,7 +126,7 @@ public static void extractTestFiles() throws IOException {
104126

105127
AssetManager testAssetManager = instrumentation.getContext().getAssets();
106128

107-
for (String filename: new String[] {"test.odt", "dummy.pdf"}) {
129+
for (String filename: new String[] {"test.odt", "dummy.pdf", "password-test.odt"}) {
108130
File targetFile = new File(testDocumentsDir, filename);
109131
try (InputStream inputStream = testAssetManager.open(filename)) {
110132
copy(inputStream, targetFile);
@@ -135,21 +157,21 @@ public void testODT() {
135157
);
136158

137159
onView(allOf(withId(R.id.menu_open), withContentDescription("Open document"), isDisplayed()))
138-
.perform(click());
160+
.perform(click());
139161

140162
// The menu item could be either Documents or Files.
141163
onView(allOf(withId(android.R.id.text1), anyOf(withText("Documents"), withText("Files")), isDisplayed()))
142164
.perform(click());
143165

144166
// next onView will be blocked until m_idlingResource is idle.
145167
onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isEnabled()))
146-
.withFailureHandler((error, viewMatcher) -> {
147-
// fails on small screens, try again with overflow menu
148-
onView(allOf(withContentDescription("More options"), isDisplayed())).perform(click());
168+
.withFailureHandler((error, viewMatcher) -> {
169+
// fails on small screens, try again with overflow menu
170+
onView(allOf(withContentDescription("More options"), isDisplayed())).perform(click());
149171

150-
onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isDisplayed()))
151-
.perform(click());
152-
});
172+
onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isDisplayed()))
173+
.perform(click());
174+
});
153175
}
154176

155177
@Test
@@ -183,5 +205,76 @@ public void testPDF() {
183205
onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isDisplayed()))
184206
.perform(click());
185207
});
208+
209+
try {
210+
Thread.sleep(10000);
211+
} catch (InterruptedException e) {
212+
throw new RuntimeException(e);
213+
}
214+
}
215+
216+
@Test
217+
public void testPasswordProtectedODT() {
218+
File testFile = s_testFiles.get("password-test.odt");
219+
Assert.assertNotNull(testFile);
220+
221+
// Check if the file exists and is readable
222+
Assert.assertTrue("Password test file does not exist: " + testFile.getAbsolutePath(), testFile.exists());
223+
Assert.assertTrue("Password test file is not readable: " + testFile.getAbsolutePath(), testFile.canRead());
224+
225+
// Log file info for debugging CI issues
226+
Log.d("MainActivityTests", "Password test file path: " + testFile.getAbsolutePath());
227+
Log.d("MainActivityTests", "Password test file size: " + testFile.length());
228+
Log.d("MainActivityTests", "All test files: " + s_testFiles.keySet());
229+
230+
// Double-check we're using the right file
231+
Assert.assertEquals("password-test.odt file size mismatch", 12671L, testFile.length());
232+
233+
Context appCtx = InstrumentationRegistry.getInstrumentation().getTargetContext();
234+
Uri testFileUri = FileProvider.getUriForFile(appCtx, appCtx.getPackageName() + ".provider", testFile);
235+
Intents.intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(
236+
new Instrumentation.ActivityResult(Activity.RESULT_OK,
237+
new Intent()
238+
.setData(testFileUri)
239+
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
240+
)
241+
);
242+
243+
onView(allOf(withId(R.id.menu_open), withContentDescription("Open document"), isDisplayed()))
244+
.perform(click());
245+
246+
onView(allOf(withId(android.R.id.text1), anyOf(withText("Documents"), withText("Files")), isDisplayed()))
247+
.perform(click());
248+
249+
// Wait for the password dialog to appear
250+
onView(withText("This document is password-protected"))
251+
.check(matches(isDisplayed()));
252+
253+
// Enter wrong password first
254+
onView(withClassName(equalTo("android.widget.EditText")))
255+
.perform(typeText("wrongpassword"));
256+
257+
onView(withId(android.R.id.button1))
258+
.perform(click());
259+
260+
// Should show password dialog again for wrong password
261+
onView(withText("This document is password-protected"))
262+
.check(matches(isDisplayed()));
263+
264+
// Clear the text field and enter correct password
265+
onView(withClassName(equalTo("android.widget.EditText")))
266+
.perform(clearText(), typeText("passwort"));
267+
268+
onView(withId(android.R.id.button1))
269+
.perform(click());
270+
271+
// Check if edit button becomes available (indicating successful load)
272+
onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isEnabled()))
273+
.withFailureHandler((error, viewMatcher) -> {
274+
onView(allOf(withContentDescription("More options"), isDisplayed())).perform(click());
275+
276+
onView(allOf(withId(R.id.menu_edit), withContentDescription("Edit document"), isDisplayed()))
277+
.perform(click());
278+
});
186279
}
187280
}

app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
33
xmlns:tools="http://schemas.android.com/tools"
44
android:installLocation="auto"
5-
android:versionCode="192"
6-
android:versionName="3.38"
5+
android:versionCode="195"
6+
android:versionName="3.40"
77
tools:ignore="GoogleAppIndexingWarning">
88

99
<uses-permission android:name="android.permission.INTERNET" />

app/src/main/cpp/core_wrapper.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,13 +269,22 @@ Java_at_tomtasche_reader_background_CoreWrapper_backtranslateNative(JNIEnv *env,
269269
jfieldID outputPathField = env->GetFieldID(resultClass, "outputPath", "Ljava/lang/String;");
270270
env->SetObjectField(result, outputPathField, outputPath);
271271

272+
__android_log_print(ANDROID_LOG_DEBUG, "smn", "HTML diff: %s", htmlDiffC);
273+
272274
try {
273275
odr::html::edit(*s_document, htmlDiffC);
274276

275277
env->ReleaseStringUTFChars(htmlDiff, htmlDiffC);
278+
} catch (const std::exception &e) {
279+
env->ReleaseStringUTFChars(htmlDiff, htmlDiffC);
280+
281+
__android_log_print(ANDROID_LOG_ERROR, "smn", "Failed to edit document: %s", e.what());
282+
env->SetIntField(result, errorField, -6);
283+
return result;
276284
} catch (...) {
277285
env->ReleaseStringUTFChars(htmlDiff, htmlDiffC);
278286

287+
__android_log_print(ANDROID_LOG_ERROR, "smn", "Failed to edit document: unknown exception");
279288
env->SetIntField(result, errorField, -6);
280289
return result;
281290
}

app/src/main/java/at/tomtasche/reader/background/CoreWrapper.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public static void initialize(Context context) {
4949
public static class CoreOptions {
5050
public boolean ooxml;
5151
public boolean txt;
52+
// TODO: remove
5253
public boolean pdf;
5354

5455
public boolean editable;

0 commit comments

Comments
 (0)