diff --git a/modules/native-file/android/src/main/java/expo/modules/nativefile/NativeFileModule.kt b/modules/native-file/android/src/main/java/expo/modules/nativefile/NativeFileModule.kt index fcf7f3a7..3a957a4d 100644 --- a/modules/native-file/android/src/main/java/expo/modules/nativefile/NativeFileModule.kt +++ b/modules/native-file/android/src/main/java/expo/modules/nativefile/NativeFileModule.kt @@ -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 { + 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 { + 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 { diff --git a/modules/native-file/ios/NativeFileModule.swift b/modules/native-file/ios/NativeFileModule.swift index 5a841c5f..e82f6352 100644 --- a/modules/native-file/ios/NativeFileModule.swift +++ b/modules/native-file/ios/NativeFileModule.swift @@ -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) } diff --git a/modules/native-file/src/NativeFileModule.ts b/modules/native-file/src/NativeFileModule.ts index 924f1607..0f97716d 100644 --- a/modules/native-file/src/NativeFileModule.ts +++ b/modules/native-file/src/NativeFileModule.ts @@ -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; readFile(path: string): Promise; copyFile(filepath: string, destPath: string): Promise; + copyFileToDirectory( + sourcePath: string, + directoryUri: string, + fileName: string, + mimeType: string, + replace: boolean, + ): Promise; moveFile(filepath: string, destPath: string): Promise; exists(filepath: string): Promise; mkdir(filepath: string): Promise; diff --git a/package.json b/package.json index e652f75e..d5cb23b6 100644 --- a/package.json +++ b/package.json @@ -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" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50749c45..d707f3f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/screens/novel/components/ExportEpubModal.tsx b/src/screens/novel/components/ExportEpubModal.tsx index feec7aa9..ddcc1cd5 100644 --- a/src/screens/novel/components/ExportEpubModal.tsx +++ b/src/screens/novel/components/ExportEpubModal.tsx @@ -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 = ({ 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)); } diff --git a/src/screens/novel/components/ExportNovelAsEpubButton.tsx b/src/screens/novel/components/ExportNovelAsEpubButton.tsx index 1a10a0d9..5be1307a 100644 --- a/src/screens/novel/components/ExportNovelAsEpubButton.tsx +++ b/src/screens/novel/components/ExportNovelAsEpubButton.tsx @@ -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 = ({ let resolvedDestinationUri = destinationUri; if (!resolvedDestinationUri) { - const selectedFolder = await openDocumentTree(true); - if (!selectedFolder) { - return; - } + const selectedFolder = await NativeFile.pickDirectory(); resolvedDestinationUri = selectedFolder.uri; } diff --git a/src/screens/novel/components/__tests__/NovelScreenList.test.tsx b/src/screens/novel/components/__tests__/NovelScreenList.test.tsx index b66ded04..17966f0c 100644 --- a/src/screens/novel/components/__tests__/NovelScreenList.test.tsx +++ b/src/screens/novel/components/__tests__/NovelScreenList.test.tsx @@ -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', () => ({ diff --git a/src/screens/updates/__tests__/UpdatesScreen.test.tsx b/src/screens/updates/__tests__/UpdatesScreen.test.tsx new file mode 100644 index 00000000..6fa6600e --- /dev/null +++ b/src/screens/updates/__tests__/UpdatesScreen.test.tsx @@ -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( + , + ); + + const focusCallback = mockUseFocusEffect.mock.calls.at(-1)?.[0]; + expect(focusCallback).toBeDefined(); + + focusCallback?.(); + + expect(mockGetUpdates).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/epub/__tests__/export.test.ts b/src/services/epub/__tests__/export.test.ts new file mode 100644 index 00000000..77241207 --- /dev/null +++ b/src/services/epub/__tests__/export.test.ts @@ -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', + ); + }); +}); diff --git a/src/services/epub/export.ts b/src/services/epub/export.ts index d88e3008..ee3f3144 100644 --- a/src/services/epub/export.ts +++ b/src/services/epub/export.ts @@ -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(), diff --git a/test/mocks/nativeModules.js b/test/mocks/nativeModules.js index e1469c7b..9ba7dbeb 100644 --- a/test/mocks/nativeModules.js +++ b/test/mocks/nativeModules.js @@ -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(),