Fix PDF export crash on desktop (#789)

ProGuard stripped org.apache.commons.logging.impl.LogFactoryImpl, which
LogFactory resolves by name, so PDDocument's static init threw
ExceptionInInitializerError on every PDF export from a packaged build.
Only pdfkmp-jvm pulls in PDFBox, so Android is unaffected.

Export failures also went uncaught in ExportDirectoryPicker on all three
platforms, killing the app instead of showing the existing failure toast.
This commit is contained in:
Adam Brown 2026-08-17 21:27:17 -07:00
parent 540392a7a7
commit 5c3d90ac7f
4 changed files with 54 additions and 16 deletions

View file

@ -19,6 +19,8 @@ import com.darkrockstudios.apps.hammer.common.fileio.ExternalFileIo
import com.darkrockstudios.apps.hammer.common.getCacheDirectory
import com.darkrockstudios.apps.hammer.project_home_action_export_toast_failure
import com.darkrockstudios.apps.hammer.project_home_action_export_toast_success
import io.github.aakira.napier.Napier
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.File
@ -42,22 +44,30 @@ actual fun ExportDirectoryPicker(
val options = confirmedOptions
if (uri != null && options != null) {
scope.launch(ioDispatcher) {
val exportTempFile = getCacheDirectory()
val tempFilePath = component.exportProject(exportTempFile, options)
val tempFile = File(tempFilePath.path)
val bytes = tempFile.readBytes()
tempFile.delete()
// A renderer failure must surface as a toast; an uncaught throw here takes down the app.
try {
val exportTempFile = getCacheDirectory()
val tempFilePath = component.exportProject(exportTempFile, options)
val tempFile = File(tempFilePath.path)
val bytes = tempFile.readBytes()
tempFile.delete()
val ok = externalFileIo.writeExternalFile(
path = uri.toString(),
content = bytes,
)
val toast = if (ok) {
Res.string.project_home_action_export_toast_success
} else {
Res.string.project_home_action_export_toast_failure
val ok = externalFileIo.writeExternalFile(
path = uri.toString(),
content = bytes,
)
val toast = if (ok) {
Res.string.project_home_action_export_toast_success
} else {
Res.string.project_home_action_export_toast_failure
}
component.showToast(toast)
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
Napier.e("Story export failed", e)
component.showToast(Res.string.project_home_action_export_toast_failure)
}
component.showToast(toast)
}
} else {
component.endProjectExport()

View file

@ -6,10 +6,13 @@ import com.darkrockstudios.apps.hammer.Res
import com.darkrockstudios.apps.hammer.common.components.projecthome.ProjectHome
import com.darkrockstudios.apps.hammer.common.components.projecthome.fileExtension
import com.darkrockstudios.apps.hammer.common.compose.rememberDefaultDispatcher
import com.darkrockstudios.apps.hammer.project_home_action_export_toast_failure
import com.darkrockstudios.apps.hammer.project_home_action_export_toast_success
import io.github.aakira.napier.Napier
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.absolutePath
import io.github.vinceglb.filekit.dialogs.openFileSaver
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -33,8 +36,16 @@ actual fun ExportDirectoryPicker(
val file = FileKit.openFileSaver(suggestedName = baseName, defaultExtension = extension)
if (file != null) {
scope.launch(defaultDispatcher) {
component.exportProjectToFile(file.absolutePath(), options)
component.showToast(Res.string.project_home_action_export_toast_success)
// A renderer failure must surface as a toast; an uncaught throw here takes down the app.
try {
component.exportProjectToFile(file.absolutePath(), options)
component.showToast(Res.string.project_home_action_export_toast_success)
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
Napier.e("Story export failed", e)
component.showToast(Res.string.project_home_action_export_toast_failure)
}
}
} else {
component.endProjectExport()

View file

@ -10,11 +10,14 @@ import com.darkrockstudios.apps.hammer.Res
import com.darkrockstudios.apps.hammer.common.components.projecthome.ProjectHome
import com.darkrockstudios.apps.hammer.common.compose.rememberDefaultDispatcher
import com.darkrockstudios.apps.hammer.common.data.ExportOptions
import com.darkrockstudios.apps.hammer.project_home_action_export_toast_failure
import com.darkrockstudios.apps.hammer.project_home_action_export_toast_success
import io.github.aakira.napier.Napier
import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher
import io.github.vinceglb.filekit.path
import io.github.vinceglb.filekit.startAccessingSecurityScopedResource
import io.github.vinceglb.filekit.stopAccessingSecurityScopedResource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -37,9 +40,15 @@ actual fun ExportDirectoryPicker(
// posix sink can't open — use .path for the bare filesystem path. UIDocumentPicker URLs are
// security-scoped, so writes outside the sandbox require start/stopAccessingSecurityScopedResource.
val accessGranted = directory.startAccessingSecurityScopedResource()
// A renderer failure must surface as a toast; an uncaught throw here takes down the app.
try {
component.exportProject(directory.path, options)
component.showToast(Res.string.project_home_action_export_toast_success)
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
Napier.e("Story export failed", e)
component.showToast(Res.string.project_home_action_export_toast_failure)
} finally {
if (accessGranted) directory.stopAccessingSecurityScopedResource()
}

View file

@ -91,6 +91,14 @@
################################################################################
-keep class * implements org.slf4j.spi.SLF4JServiceProvider { *; }
################################################################################
# COMMONS-LOGGING (PDFBox, used by PDF export)
# LogFactory resolves its implementation by name, so the shrinker sees no
# reference to LogFactoryImpl and drops it. PDDocument's static init then dies.
################################################################################
-keep class org.apache.commons.logging.** { *; }
-dontwarn org.apache.commons.logging.**
################################################################################
# CLEANUP & WARNING SUPPRESSION
################################################################################