This document contains solutions to common issues encountered while developing or using the Letterbox app.
- App crashes immediately after user supplies an email file
- Crash occurs when opening from SAF framework or choosing the app as default opener
- WebView fails to render HTML content
The shouldInterceptRequest method in EmailWebView was unconditionally returning a 403 Forbidden response for all non-cid: URLs. This blocked all HTTP/HTTPS requests even when allowNetworkLoads was true, and also blocked other necessary WebView requests that the WebView needs to function properly.
The fix involved updating the shouldInterceptRequest logic in EmailDetailScreen.kt:
-
Allow HTTP/HTTPS requests when remote images are enabled: When
allowNetworkLoadsis true, the method now returnsnullfor HTTP/HTTPS URLs, allowing WebView to handle them normally (with privacy proxy if configured). -
Block HTTP/HTTPS requests for security: When
allowNetworkLoadsis false, the method returns a 403 Forbidden response for HTTP/HTTPS URLs to protect privacy. -
Let WebView handle other schemes: For other URL schemes (like data:, javascript:, etc.), the method returns
nullto let WebView's default behavior take over.
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
val url = request?.url?.toString() ?: return null
// Intercept cid: URLs for inline images
if (url.startsWith("cid:")) {
// ... handle cid: URLs
}
// Allow network loads when explicitly enabled (for remote images)
if (allowNetworkLoads && (url.startsWith("http://") || url.startsWith("https://"))) {
return null // Let WebView handle it normally
}
// Block all other external requests with a clear error
if (url.startsWith("http://") || url.startsWith("https://")) {
return WebResourceResponse(/* 403 Forbidden */)
}
// For other schemes, return null to let WebView handle them
return null
}To prevent this regression, end-to-end tests were added in EmailOpeningE2ETest.kt:
openEmlFile_viaActionView_displaysEmailContent- Tests opening via ACTION_VIEW intentopenEmlFile_viaActionSend_displaysEmailContent- Tests opening via ACTION_SEND intentopenEmlFile_displaysHtmlContentWithoutCrash- Specifically verifies HTML rendering doesn't crash
Test EML files are located in app/src/androidTest/assets/:
test_simple.eml- Basic HTML emailtest_with_images.eml- Email with remote images
Run the instrumented tests to verify the fix:
./gradlew :app:connectedAndroidTestOr run unit tests:
./gradlew :app:testProdDebugUnitTest- Email content area appears blank
- HTML body doesn't render
- Incorrect Content-Type: Ensure the HTML is loaded with proper MIME type (
text/html) - Base URL issues: WebView may need a base URL for relative resources
- JavaScript disabled: Some email HTML may require JavaScript (though it's disabled for security)
The app uses loadDataWithBaseURL(null, html, "text/html", "utf-8", null) which should handle most cases. If issues persist:
- Check the HTML content is valid
- Verify inline resources use cid: URLs and are handled by
shouldInterceptRequest - Review WebView settings in
EmailWebViewcomposable
- Images in emails do not appear after tapping "Show Images".
- Image placeholder icons are visible but images do not load.
The shouldInterceptRequest method blocks HTTP/HTTPS requests by default for privacy.
This is expected behavior. To load images:
- Tap the "Show Images" button in the remote images banner.
- Images load through the privacy proxy (if enabled).
The setting can be changed in Settings:
- "Always load remote images": Loads images automatically.
- "Use privacy proxy": Routes images through the WARP privacy proxy.
- Instrumented tests start but crash during execution
- Error message: "Instrumentation run failed due to Process crashed"
- Some tests pass before the crash occurs
- The failing test may not be the one that actually caused the crash
FFI (Foreign Function Interface) calls to the Rust native library may fail if the native library is not available or fails to load. In Kotlin/Java, when a native library fails to load, it throws UnsatisfiedLinkError or ExceptionInInitializerError, which are Error types, NOT Exception types.
If FFI calls are wrapped in catch (e: Exception) blocks, these errors are NOT caught, causing the process to crash.
All FFI calls must catch both Exception AND Error types:
val result = try {
someFfiFunction()
} catch (e: Exception) {
fallbackValue
} catch (e: UnsatisfiedLinkError) {
// Native library not available
fallbackValue
} catch (e: ExceptionInInitializerError) {
// Library initialization failed
fallbackValue
}The following FFI functions in this codebase require this pattern:
parseEml()/parseEmlFromPath()- Already properly handledextractRemoteImages()- Fixed in EmailViewModel.ktrewriteImageUrls()- Fixed in EmailDetailScreen.kt
When adding new FFI calls, always:
- Wrap in try-catch
- Catch both
ExceptionandErrortypes (UnsatisfiedLinkError,ExceptionInInitializerError) - Provide a sensible fallback (e.g., empty list, original content, false)
- Instrumented tests fail with
java.io.FileNotFoundException: test_simple.eml - Tests that access test assets crash with asset not found errors
Test assets (files in app/src/androidTest/assets/) are packaged into the test APK, not the application APK. Using ApplicationProvider.getApplicationContext().assets accesses the application APK's assets, which doesn't contain test assets.
Use InstrumentationRegistry.getInstrumentation().context to access test APK assets:
// Wrong: Uses application context (app APK assets)
val context = ApplicationProvider.getApplicationContext()
val content = context.assets.open("test_file.eml")
// Correct: Uses instrumentation context (test APK assets)
val testContext = InstrumentationRegistry.getInstrumentation().context
val content = testContext.assets.open("test_file.eml")Note: For file operations (cache, shared preferences), continue using ApplicationProvider.getApplicationContext() as those need access to the app's storage.
- Tests fail with
java.lang.IllegalArgumentException: Failed to find configured root that contains /data/data/.../cache/file.eml - FileProvider.getUriForFile() throws exception
The FileProvider is configured with specific paths in res/xml/file_paths.xml. If you write a file to a directory that isn't configured, FileProvider cannot create a URI for it.
Write test files to a directory that's already configured in file_paths.xml:
// Wrong: Writing to cache root (not configured)
val file = File(context.cacheDir, "test.eml")
// Correct: Writing to "shared/" subdirectory (configured in file_paths.xml)
val sharedDir = File(context.cacheDir, "shared")
sharedDir.mkdirs()
val file = File(sharedDir, "test.eml")Check app/src/main/res/xml/file_paths.xml to see which paths are configured:
<paths>
<cache-path name="attachments" path="attachments/" />
<cache-path name="shared" path="shared/" />
</paths>- Tests that expect "empty state" fail inconsistently
- Tests pass individually but fail when run as a suite
- Error like "The component is not displayed!" for empty state message
Instrumented tests share the same application database. Tests that add data (like EmailOpeningE2ETest which opens email files) can leave data in the database that affects subsequent tests expecting an empty state.
Clear the database in @Before methods for tests that require a clean state:
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.joefang.letterbox.data.LetterboxDatabase
import org.junit.Before
@RunWith(AndroidJUnit4::class)
class HomeScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Before
fun setup() {
// Clear history database to ensure tests start with empty state
val context = InstrumentationRegistry.getInstrumentation().targetContext
runBlocking {
LetterboxDatabase.getInstance(context).historyItemDao().deleteAll()
}
}
@Test
fun homeScreen_displaysEmptyStateMessage() {
// This test now reliably sees empty state
composeTestRule.onNodeWithText("Open an .eml or .msg file").assertIsDisplayed()
}
}Note: Use targetContext (application context) for database operations, not context (test APK context).
When writing new tests that depend on application state:
- Always clear relevant data in
@Beforemethod - Consider using Room's in-memory database for unit tests
- Use
@Afterto clean up if necessary
Instrumented tests require an Android device or emulator:
# List available devices
adb devices
# Run tests on connected device
./gradlew :app:connectedAndroidTest
# Or use managed device (auto-provisions an emulator - this is how CI runs)
./gradlew pixel7Api34StagingDebugAndroidTestNote: Managed device tests automatically download and provision an Android emulator image. The first run takes longer as it downloads the image. Subsequent runs are faster.
Available managed device configurations:
pixel7Api34StagingDebugAndroidTest- Pixel 7 with API 34 (Android 14)allDevicesStagingDebugAndroidTest- All configured managed devices
Unit tests can run on the host machine:
./gradlew :app:testProdDebugUnitTestThese require the Rust library to be built:
cd rust/letterbox-core
cargo build --release --libIf you see errors about missing native library:
-
Build the Rust library:
./gradlew :app:cargoHostBuild # For unit tests ./gradlew :app:cargoNdkBuild -PrustBuild=true # For Android
-
Ensure cargo-ndk is installed:
cargo install cargo-ndk
If Gradle sync fails:
-
Clean the project:
./gradlew clean
-
Invalidate caches in Android Studio: File > Invalidate Caches / Restart
-
Check Gradle version compatibility in
gradle/wrapper/gradle-wrapper.properties
- Check existing issues: https://github.com/BTreeMap/Letterbox/issues
- Read the main README: README.md
- Review architecture: architecture.md