fix: Replace EPUB Exports Atomically With NativeFile (Closes #1954)

This commit is contained in:
Rajarshee Chatterjee
2026-08-04 19:32:12 +05:30
parent 23f9b183be
commit e4246dee59
12 changed files with 520 additions and 36 deletions
@@ -1,6 +1,7 @@
package expo.modules.nativefile
import android.app.Activity
import android.content.ContentResolver
import android.content.Intent
import android.net.Uri
import android.os.Build
@@ -33,6 +34,7 @@ import java.io.FileWriter
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.UUID
import java.io.PushbackInputStream
import java.util.zip.GZIPInputStream
import kotlin.coroutines.coroutineContext
@@ -119,9 +121,10 @@ class NativeFileModule : Module() {
filepath: String,
destPath: String,
onDone: (() -> Unit)? = null,
) {
): Long {
try {
val inputStream = getInputStream(filepath)
var copiedBytes = 0L
try {
val outputStream = getOutputStream(destPath)
try {
@@ -130,7 +133,9 @@ class NativeFileModule : Module() {
while (inputStream.read(buffer).also { length = it } > 0) {
coroutineContext.ensureActive()
outputStream.write(buffer, 0, length)
copiedBytes += length
}
outputStream.flush()
} finally {
outputStream.close()
}
@@ -140,11 +145,201 @@ class NativeFileModule : Module() {
if (onDone != null) {
onDone()
}
return copiedBytes
} catch (e: IOException) {
throw Exception("Failed to copy file from '$filepath' to '$destPath': ${e.message}")
}
}
private fun contentResolver(): ContentResolver =
reactContext?.contentResolver
?: throw IOException("React context is unavailable")
private suspend fun copyToOutputStream(sourcePath: String, outputStream: OutputStream): Long {
var copiedBytes = 0L
getInputStream(sourcePath).use { inputStream ->
outputStream.use { output ->
val buffer = ByteArray(BUFFER_SIZE)
var length: Int
while (inputStream.read(buffer).also { length = it } > 0) {
coroutineContext.ensureActive()
output.write(buffer, 0, length)
copiedBytes += length
}
output.flush()
}
}
return copiedBytes
}
private fun resolveDirectoryFile(directoryUri: String): File {
val uri = Uri.parse(directoryUri)
val directory = if (uri.scheme == null) {
File(directoryUri)
} else if (uri.scheme == ContentResolver.SCHEME_FILE) {
File(uri.path ?: throw IOException("Invalid directory URI: '$directoryUri'"))
} else {
throw IOException("Unsupported filesystem directory URI: '$directoryUri'")
}
if (!directory.isDirectory) {
throw IOException("Destination directory does not exist: '$directoryUri'")
}
return directory
}
private suspend fun copyFileToFilesystemDirectory(
sourcePath: String,
directoryUri: String,
fileName: String,
replace: Boolean,
): Map<String, Any> {
val directory = resolveDirectoryFile(directoryUri)
val destination = File(directory, fileName)
if (destination.exists() && !replace) {
throw IOException("File already exists: ${destination.absolutePath}")
}
val staging = File(directory, ".$fileName.${UUID.randomUUID()}.tmp")
val backup = File(directory, ".$fileName.${UUID.randomUUID()}.bak")
try {
val copiedBytes = FileOutputStream(staging).use { output ->
copyToOutputStream(sourcePath, output)
}
if (staging.length() != copiedBytes) {
throw IOException("Copied file size does not match the source stream")
}
val hadDestination = destination.exists()
if (hadDestination && !destination.renameTo(backup)) {
throw IOException("Could not stage the existing destination for replacement")
}
if (!staging.renameTo(destination)) {
if (hadDestination) {
backup.renameTo(destination)
}
throw IOException("Could not move the completed copy into the destination")
}
if (backup.exists() && !backup.delete()) {
throw IOException("Could not remove the replaced destination backup")
}
return mapOf("uri" to destination.absolutePath, "size" to copiedBytes)
} finally {
staging.delete()
if (backup.exists() && !destination.exists()) {
backup.renameTo(destination)
}
}
}
private fun resolveTreeDirectoryUri(treeUri: Uri): Uri {
if (!DocumentsContract.isTreeUri(treeUri)) {
throw IOException("Destination is not a Storage Access Framework directory: '$treeUri'")
}
val documentId = DocumentsContract.getTreeDocumentId(treeUri)
return DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId)
}
private fun findChildDocument(treeUri: Uri, directoryUri: Uri, fileName: String): Uri? {
val resolver = contentResolver()
val documentId = DocumentsContract.getDocumentId(directoryUri)
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
)
resolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
if (cursor.getString(1) == fileName) {
return DocumentsContract.buildDocumentUriUsingTree(treeUri, cursor.getString(0))
}
}
}
return null
}
private fun renameDocument(documentUri: Uri, displayName: String): Uri =
DocumentsContract.renameDocument(contentResolver(), documentUri, displayName)
?: throw IOException("Could not rename destination document to '$displayName'")
private suspend fun copyFileToSafDirectory(
sourcePath: String,
directoryUriString: String,
fileName: String,
mimeType: String,
replace: Boolean,
): Map<String, Any> {
val resolver = contentResolver()
val treeUri = Uri.parse(directoryUriString)
val directoryUri = resolveTreeDirectoryUri(treeUri)
val existing = findChildDocument(treeUri, directoryUri, fileName)
if (existing != null && !replace) {
throw IOException("File already exists: $fileName")
}
val token = UUID.randomUUID().toString()
val stagingName = "$fileName.lnreader-$token.tmp"
val backupName = "$fileName.lnreader-$token.bak"
var stagingUri = DocumentsContract.createDocument(
resolver,
directoryUri,
mimeType,
stagingName,
) ?: throw IOException("Could not create a temporary destination document")
var backupUri: Uri? = null
var completedUri: Uri? = null
try {
val copiedBytes = copyToOutputStream(
sourcePath,
resolver.openOutputStream(stagingUri, writeAccessByAPILevel)
?: throw IOException("Could not open the temporary destination document"),
)
if (existing != null) {
backupUri = renameDocument(existing, backupName)
}
try {
completedUri = renameDocument(stagingUri, fileName)
stagingUri = completedUri
} catch (error: Exception) {
backupUri?.let {
try {
renameDocument(it, fileName)
backupUri = null
} catch (_: Exception) {
// Keep the original replacement error.
}
}
throw error
}
backupUri?.let {
try {
DocumentsContract.deleteDocument(resolver, it)
} catch (_: Exception) {
// The completed destination is already in place.
}
backupUri = null
}
return mapOf("uri" to completedUri.toString(), "size" to copiedBytes)
} finally {
if (completedUri == null) {
try {
DocumentsContract.deleteDocument(resolver, stagingUri)
} catch (_: Exception) {
// Preserve the original copy or replacement error.
}
}
if (completedUri == null) backupUri?.let {
try {
renameDocument(it, fileName)
} catch (_: Exception) {
// Preserve the original copy or replacement error.
}
}
}
}
private suspend fun deleteRecursive(fileOrDirectory: File) {
coroutineContext.ensureActive()
if (fileOrDirectory.isDirectory) {
@@ -262,6 +457,25 @@ class NativeFileModule : Module() {
}
}
AsyncFunction("copyFileToDirectory") { sourcePath: String, directoryUri: String, fileName: String, mimeType: String, replace: Boolean, promise: Promise ->
coroutineScope.launch {
try {
require(fileName.isNotBlank() && fileName != "." && fileName != ".." && !fileName.contains('/') && !fileName.contains('\\')) {
"Invalid destination file name"
}
val uri = Uri.parse(directoryUri)
val result = if (uri.scheme == ContentResolver.SCHEME_CONTENT) {
copyFileToSafDirectory(sourcePath, directoryUri, fileName, mimeType, replace)
} else {
copyFileToFilesystemDirectory(sourcePath, directoryUri, fileName, replace)
}
promise.resolve(result)
} catch (e: Exception) {
rejectFileOperation(promise, "copy into directory", directoryUri, e)
}
}
}
AsyncFunction("moveFile") { filepath: String, destPath: String, promise: Promise ->
coroutineScope.launch {
try {
@@ -17,6 +17,55 @@ public class NativeFileModule: Module {
try FileManager.default.copyItem(atPath: sourcePath, toPath: destPath)
}
AsyncFunction("copyFileToDirectory") { (sourcePath: String, directoryUri: String, fileName: String, mimeType: String, replace: Bool) -> [String: Any] in
_ = mimeType
guard !fileName.isEmpty,
fileName != ".",
fileName != "..",
!fileName.contains("/"),
!fileName.contains("\\") else {
throw NSError(
domain: "NativeFile",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid destination file name"]
)
}
let fileManager = FileManager.default
let directoryURL: URL
if let parsedURL = URL(string: directoryUri), parsedURL.isFileURL {
directoryURL = parsedURL
} else {
directoryURL = URL(fileURLWithPath: directoryUri, isDirectory: true)
}
let sourceURL = URL(fileURLWithPath: sourcePath)
let destinationURL = directoryURL.appendingPathComponent(fileName)
let stagingURL = directoryURL.appendingPathComponent(".\(fileName).\(UUID().uuidString).tmp")
guard fileManager.fileExists(atPath: directoryURL.path) else {
throw NSError(
domain: "NativeFile",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Destination directory does not exist"]
)
}
if fileManager.fileExists(atPath: destinationURL.path) && !replace {
throw CocoaError(.fileWriteFileExists)
}
defer { try? fileManager.removeItem(at: stagingURL) }
try fileManager.copyItem(at: sourceURL, to: stagingURL)
let attributes = try fileManager.attributesOfItem(atPath: stagingURL.path)
let copiedSize = (attributes[.size] as? NSNumber)?.int64Value ?? 0
if fileManager.fileExists(atPath: destinationURL.path) {
_ = try fileManager.replaceItemAt(destinationURL, withItemAt: stagingURL)
} else {
try fileManager.moveItem(at: stagingURL, to: destinationURL)
}
return ["uri": destinationURL.absoluteString, "size": copiedSize]
}
Function("moveFile") { (sourcePath: String, destPath: String) in
try FileManager.default.moveItem(atPath: sourcePath, toPath: destPath)
}
@@ -11,6 +11,11 @@ export type DirectorySelection = {
name: string;
};
export type FileCopyResult = {
uri: string;
size: number;
};
type NativeFileModule = {
DocumentDirectoryPath: string;
ExternalDirectoryPath: string;
@@ -21,6 +26,13 @@ type NativeFileModule = {
writeFile(path: string, content: string): Promise<void>;
readFile(path: string): Promise<string>;
copyFile(filepath: string, destPath: string): Promise<void>;
copyFileToDirectory(
sourcePath: string,
directoryUri: string,
fileName: string,
mimeType: string,
replace: boolean,
): Promise<FileCopyResult>;
moveFile(filepath: string, destPath: string): Promise<void>;
exists(filepath: string): Promise<boolean>;
mkdir(filepath: string): Promise<void>;
-2
View File
@@ -90,7 +90,6 @@
"react-native-pager-view": "^8.0.2",
"react-native-paper": "^5.15.3",
"react-native-reanimated": "^4.5.0",
"react-native-saf-x": "^2.2.3",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.25.2",
"react-native-shimmer-placeholder": "^2.0.9",
@@ -151,7 +150,6 @@
"nitro-epub",
"native-background-tasks",
"@react-native-vector-icons/material-design-icons",
"react-native-saf-x",
"lottie-ios"
]
}
-14
View File
@@ -173,9 +173,6 @@ importers:
react-native-reanimated:
specifier: ^4.5.0
version: 4.5.2(react-native-worklets@0.10.2(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
react-native-saf-x:
specifier: ^2.2.3
version: 2.2.3(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
react-native-safe-area-context:
specifier: ^5.7.0
version: 5.8.0(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
@@ -5244,12 +5241,6 @@ packages:
react-native: 0.83 - 0.86
react-native-worklets: 0.10.x - 0.11.x
react-native-saf-x@2.2.3:
resolution: {integrity: sha512-aPQbUfuHy8txZ/+t8Daoy8G3aRZ5H+0/SH9mJdb6quvxvt48jeIjq2n82anV77tSCXyX0du955PrtWpRzYliPQ==}
peerDependencies:
react: '*'
react-native: '*'
react-native-safe-area-context@5.8.0:
resolution: {integrity: sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==}
peerDependencies:
@@ -12196,11 +12187,6 @@ snapshots:
react-native-worklets: 0.10.2(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0)
semver: 7.8.5
react-native-saf-x@2.2.3(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3):
dependencies:
react: 19.2.3
react-native: 0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0)
react-native-safe-area-context@5.8.0(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3):
dependencies:
react: 19.2.3
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { HelperText, TextInput } from 'react-native-paper';
import { openDocumentTree } from 'react-native-saf-x';
import { Dialog, SwitchItem } from '@components';
import NativeFile from '@modules/native-file';
import { useBoolean } from '@hooks';
import { getString } from '@i18n/translations';
@@ -138,10 +138,8 @@ const ExportEpubModal: React.FC<ExportEpubModalProps> = ({
const openFolderPicker = async () => {
try {
const resultUri = await openDocumentTree(true);
if (resultUri) {
setUri(resultUri.uri);
}
const result = await NativeFile.pickDirectory();
setUri(result.uri);
} catch (error) {
showToast(error instanceof Error ? error.message : String(error));
}
@@ -1,9 +1,9 @@
import React, { useMemo } from 'react';
import { Portal } from 'react-native-paper';
import { StatusBar } from 'react-native';
import { openDocumentTree } from 'react-native-saf-x';
import { type EpubExportChapter } from '@modules/nitro-epub';
import NativeFile from '@modules/native-file';
import { NovelInfo } from '@database/types';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
@@ -125,10 +125,7 @@ const ExportNovelAsEpubButton: React.FC<ExportNovelAsEpubButtonProps> = ({
let resolvedDestinationUri = destinationUri;
if (!resolvedDestinationUri) {
const selectedFolder = await openDocumentTree(true);
if (!selectedFolder) {
return;
}
const selectedFolder = await NativeFile.pickDirectory();
resolvedDestinationUri = selectedFolder.uri;
}
@@ -167,9 +167,14 @@ jest.mock('@utils/showToast', () => ({
}));
jest.mock('@modules/native-file', () => ({
ExternalCachesDirectoryPath: '/tmp',
copyFile: jest.fn(),
unlink: jest.fn(),
__esModule: true,
default: {
ExternalCachesDirectoryPath: '/tmp',
copyFile: jest.fn(),
copyFileToDirectory: jest.fn(),
pickDirectory: jest.fn(),
unlink: jest.fn(),
},
}));
jest.mock('@plugins/helpers/fetch', () => ({
@@ -0,0 +1,93 @@
import { render } from '@testing-library/react-native';
import { useFocusEffect } from '@react-navigation/native';
import UpdatesScreen from '../UpdatesScreen';
const mockGetUpdates = jest.fn();
jest.mock('@components', () => {
const ReactModule = require('react');
return {
EmptyView: () => null,
ErrorScreenV2: () => null,
SearchbarV2: () => null,
SafeAreaView: ({ children }: { children: React.ReactNode }) =>
ReactModule.createElement(ReactModule.Fragment, null, children),
};
});
jest.mock('@components/Context/UpdateContext', () => ({
useUpdateContext: () => ({
updatesOverview: [],
getUpdates: mockGetUpdates,
lastUpdateTime: undefined,
showLastUpdateTime: true,
error: '',
}),
}));
jest.mock('@hooks', () => ({
useSearch: () => ({
searchText: '',
setSearchText: jest.fn(),
clearSearchbar: jest.fn(),
}),
}));
jest.mock('@hooks/persisted', () => ({
useAppSettings: () => ({}),
useTheme: () => ({
background: '#000000',
onPrimary: '#ffffff',
onSurface: '#ffffff',
primary: '#000000',
}),
}));
jest.mock('@i18n/translations', () => ({
getString: (key: string) => key,
}));
jest.mock('@database/queries/ChapterQueries', () => ({
deleteChapter: jest.fn(),
}));
jest.mock('@services/backgroundTasks', () => ({
backgroundTasks: { enqueue: jest.fn() },
}));
jest.mock('@utils/dateFormat', () => ({
formatDate: (date: string) => date,
}));
jest.mock('../components/UpdateNovelChapterGroup', () => () => null);
const mockUseFocusEffect = useFocusEffect as jest.MockedFunction<
typeof useFocusEffect
>;
describe('UpdatesScreen', () => {
beforeEach(() => {
mockGetUpdates.mockReset();
});
it('reloads the update overview when the Updates screen gains focus', () => {
const navigation = {
addListener: jest.fn(() => jest.fn()),
isFocused: jest.fn(() => false),
navigate: jest.fn(),
};
render(
<UpdatesScreen navigation={navigation as never} route={{} as never} />,
);
const focusCallback = mockUseFocusEffect.mock.calls.at(-1)?.[0];
expect(focusCallback).toBeDefined();
focusCallback?.();
expect(mockGetUpdates).toHaveBeenCalledTimes(1);
});
});
+123
View File
@@ -0,0 +1,123 @@
import NativeFile from '@modules/native-file';
import { epub } from '@modules/nitro-epub';
import type {
BackgroundTaskMetadata,
EpubExportData,
} from '@services/backgroundTasks/contracts';
import { exportEpub } from '../export';
jest.mock('@modules/nitro-epub', () => ({
epub: {
exportEpub: jest.fn(),
},
}));
jest.mock('@i18n/translations', () => ({
getString: (key: string) => key,
}));
const data: EpubExportData = {
novelName: 'Example Novel',
destinationUri: '/storage/emulated/0/Download',
fileName: 'Example Novel.epub',
chapters: [
{
title: 'Chapter 1',
htmlPath: '/novels/1/1/index.html',
novelId: '1',
chapterId: '1',
},
],
metadata: {
title: 'Example Novel',
language: 'en',
coverPath: '',
description: '',
author: '',
bookId: 'urn:lnreader:test',
stylesheet: '',
javascript: '',
},
};
const createProgressUpdater = () => {
let metadata: BackgroundTaskMetadata = {
name: 'Exporting EPUB',
isRunning: false,
progress: 0,
progressText: '',
};
return {
getMetadata: () => metadata,
updateProgress: jest.fn(transformer => {
metadata = transformer(metadata);
}),
};
};
describe('exportEpub', () => {
beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValue(1234);
jest.mocked(epub.exportEpub).mockResolvedValue({
outputPath: '/mock/caches/epub-export-1234.epub',
chapterCount: 1,
});
jest.mocked(NativeFile.copyFileToDirectory).mockResolvedValue({
uri: '/storage/emulated/0/Download/Example Novel.epub',
size: 4096,
});
jest.mocked(NativeFile.exists).mockResolvedValue(true);
jest.mocked(NativeFile.unlink).mockResolvedValue(undefined);
});
it('replaces the named destination through NativeFile', async () => {
const progress = createProgressUpdater();
await exportEpub(data, progress.updateProgress);
expect(NativeFile.copyFileToDirectory).toHaveBeenCalledWith(
'/mock/caches/epub-export-1234.epub',
'/storage/emulated/0/Download',
'Example Novel.epub',
'application/epub+zip',
true,
);
expect(progress.getMetadata()).toMatchObject({
isRunning: false,
progress: 1,
completionText: 'novelScreen.epub.exportSuccess',
});
});
it('rejects an empty destination copy instead of reporting success', async () => {
jest.mocked(NativeFile.copyFileToDirectory).mockResolvedValue({
uri: '/storage/emulated/0/Download/Example Novel.epub',
size: 0,
});
const progress = createProgressUpdater();
await expect(exportEpub(data, progress.updateProgress)).rejects.toThrow(
'Exported EPUB is empty',
);
expect(progress.getMetadata().completionText).toBeUndefined();
expect(NativeFile.unlink).toHaveBeenCalledWith(
'/mock/caches/epub-export-1234.epub',
);
});
it('cleans up the generated cache file when destination replacement fails', async () => {
jest
.mocked(NativeFile.copyFileToDirectory)
.mockRejectedValue(new Error('Destination replacement failed'));
const progress = createProgressUpdater();
await expect(exportEpub(data, progress.updateProgress)).rejects.toThrow(
'Destination replacement failed',
);
expect(NativeFile.unlink).toHaveBeenCalledWith(
'/mock/caches/epub-export-1234.epub',
);
});
});
+9 -6
View File
@@ -1,5 +1,3 @@
import { copyFile } from 'react-native-saf-x';
import NativeFile from '@modules/native-file';
import { epub } from '@modules/nitro-epub';
@@ -77,11 +75,16 @@ export const exportEpub = async (
}));
const epubFileName = `${sanitizeEpubFileName(fileName)}.epub`;
await copyFile(
`file://${result.outputPath}`,
`${destinationUri}/${epubFileName}`,
{ replaceIfDestinationExists: true },
const copyResult = await NativeFile.copyFileToDirectory(
result.outputPath,
destinationUri,
epubFileName,
'application/epub+zip',
true,
);
if (copyResult.size <= 0) {
throw new Error('Exported EPUB is empty');
}
const completionText = getString('novelScreen.epub.exportSuccess', {
chapters: result.chapterCount.toString(),
+6
View File
@@ -10,6 +10,12 @@ jest.mock('@modules/native-file', () => ({
writeFile: jest.fn(),
readFile: jest.fn(() => ''),
copyFile: jest.fn(),
copyFileToDirectory: jest.fn(() =>
Promise.resolve({ uri: '/mock/export.epub', size: 1 }),
),
pickDirectory: jest.fn(() =>
Promise.resolve({ uri: '/mock/export', name: 'export' }),
),
moveFile: jest.fn(),
exists: jest.fn(() => true),
mkdir: jest.fn(),