fix: Replace EPUB Exports Atomically With NativeFile (Closes #1954)
This commit is contained in:
+215
-1
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Generated
-14
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user