/* * Copyright (c) 2024 Huawei Device Co., Ltd. All rights reserved * Use of this source code is governed by a MIT license that can be * found in the LICENSE file. */ import { TurboModule, RNOHError, TurboModuleContext } from '@rnoh/react-native-openharmony/ts'; import { TM } from "./generated/ts" import fs, { ListFileOptions, ReadOptions, ReadTextOptions, WriteOptions } from '@ohos.file.fs'; import hash from '@ohos.file.hash'; import { BusinessError } from '@ohos.base'; import resourceManager from '@ohos.resourceManager' import util from '@ohos.util'; import loadRequest from '@ohos.request'; import buffer from '@ohos.buffer'; import HashMap from '@ohos.util.HashMap'; import { Context } from '@ohos.abilityAccessCtrl'; import common from '@ohos.app.ability.common'; const TAG: string = "[RNOH] Fs" interface StatResult { ctime: number, // The creation date of the file mtime: number, // The last modified date of the file size: number, // Size in bytes mode: number, // UNIX file mode originalFilepath: string, // ANDROID: In case of content uri this is the pointed file path, otherwise is the same as path type: number // Is the file just a file? Is the file a directory? } type Headers = { [name: string]: string } type Fields = { [name: string]: string } type DownloadFileOptions = { jobId: number fromUrl: string // URL to download file from toFile: string // Local filesystem path to save the file to headers?: Headers // An object of headers to be passed to the server background?: boolean // Continue the download in the background after the app terminates (iOS only) progressInterval?: number progressDivider?: number readTimeout?: number hasBeginCallback?: (res: DownloadBeginCallbackResult) => void hasProgressCallback?: (res: DownloadProgressCallbackResult) => void hasResumableCallback?: () => void // only supported on iOS yet connectionTimeout?: number // only supported on Android yet backgroundTimeout?: number // Maximum time (in milliseconds) to download an entire resource (iOS only, useful for timing out background downloads) } type DownloadBeginCallbackResult = { jobId: number // The download job ID, required if one wishes to cancel the download. See `stopDownload`. statusCode: number // The HTTP status code contentLength: number // The total size in bytes of the download resource headers: Headers // The HTTP response headers from the server } type DownloadProgressCallbackResult = { jobId: number // The download job ID, required if one wishes to cancel the download. See `stopDownload`. contentLength: number // The total size in bytes of the download resource bytesWritten: number // The number of bytes written to the file so far } type DownloadResult = { jobId: number // The download job ID, required if one wishes to cancel the download. See `stopDownload`. statusCode: number // The HTTP status code bytesWritten: number // The number of bytes written to the file } type UploadFileOptions = { jobId: number toUrl: string // URL to upload file to binaryStreamOnly?: boolean // Allow for binary data stream for file to be uploaded without extra headers, Default is 'false' files: UploadFileItem[] // An array of objects with the file information to be uploaded. headers?: Headers // An object of headers to be passed to the server fields?: Fields // An object of fields to be passed to the server method?: string // Default is 'POST', supports 'POST' and 'PUT' hasBeginCallback?: (res: UploadBeginCallbackResult) => void hasProgressCallback?: (res: UploadProgressCallbackResult) => void } type UploadFileItem = { name: string // Name of the file, if not defined then filename is used filename: string // Name of file filepath: string // Path to file filetype: string // The mimetype of the file to be uploaded, if not defined it will get mimetype from `filepath` extension } type UploadBeginCallbackResult = { jobId: number // The upload job ID, required if one wishes to cancel the upload. See `stopUpload`. } type UploadProgressCallbackResult = { jobId: number // The upload job ID, required if one wishes to cancel the upload. See `stopUpload`. totalBytesExpectedToSend: number // The total number of bytes that will be sent to the server totalBytesSent: number // The number of bytes sent to the server } type UploadResult = { jobId: number // The upload job ID, required if one wishes to cancel the upload. See `stopUpload`. statusCode: number // The HTTP status code headers: Headers // The HTTP response headers from the server body: string // The HTTP response body } type ReadDirItem = { ctime?: number; mtime?: number; name: string; path: string; size: number; type: number; }; enum LOADTASK_STATUS { PROGRESS = 'progress', COMPLETE = 'complete', PAUSE = 'pause', REMOVE = 'remove', FAIL = 'fail' } export class FsTurboModule extends TurboModule implements TM.ReactNativeFs.Spec { private context: Context; // ApplicationContext private resourceManager: resourceManager.ResourceManager; private FOUR_ZERO_NINE_SIX: number = 4096 private ZERO: number = 0 private ONE: number = 1 private ONE_Three_Nine_ZERO_ZERO_ZERO_ONE_FIVE: number = 13900015 private UTF8: buffer.BufferEncoding = 'utf8' private MD5: string = 'md5' private SHA1: string = 'sha1' private SHA256: string = 'sha256' private UTF_8: buffer.BufferEncoding = 'utf-8' private BASE64: buffer.BufferEncoding = 'base64' private logger = this.ctx.logger.clone("ReactNativeFsLogger") constructor(ctx: TurboModuleContext) { super(ctx) this.context = this.ctx.uiAbilityContext; this.resourceManager = this.context.resourceManager; } existsAssets(filepath: string): Promise { this.logger.info('existsAssets') return new Promise((resolve, reject) => { try { this.resourceManager.getRawFileList(filepath, (error: BusinessError, value: Array) => { if (error != null) { resolve(false); } else { resolve(true); } }); } catch (error) { this.logger.error(`existsAssets error: ${error?.message}`) resolve(false); } }); } readDir(dirpath: string): Promise { this.logger.info('readDir') return new Promise((resolve, reject) => { let listFileOption: ListFileOptions = { recursion: false, listNum: this.ZERO }; if (!dirpath.endsWith('/')) { dirpath += '/'; } fs.listFile(dirpath, listFileOption, (err: BusinessError, filenames: Array) => { if (err) { reject("list file failed with error message: " + err.message + ", error code: " + err.code); } else { try { let readDirResult: ReadDirItem[] = []; for (let i = this.ZERO; i < filenames.length; i++) { let filename = filenames[i]; let filePath = dirpath + filename; let file = fs.statSync(filePath); readDirResult.push({ ctime: file.ctime, mtime: file.mtime, name: filename, path: filePath, size: file.size, type: file.isDirectory() ? this.ONE : this.ZERO, }); } resolve(readDirResult); } catch (e) { this.logger.error(`readDir error: ${e?.message}`) reject(e) } } }); }); } downloadFile(options: Object): Promise { this.logger.info('downloadFile') return new Promise((resolve, reject) => { let downloadFileOptions: DownloadFileOptions = options as DownloadFileOptions; try { let res = fs.accessSync(downloadFileOptions.toFile); if (res) { fs.unlinkSync(downloadFileOptions.toFile); } } catch (error) { reject(error); } let downloadConfig: loadRequest.DownloadConfig = { url: downloadFileOptions.fromUrl, header: downloadFileOptions.headers, enableMetered: true, enableRoaming: true, description: "", filePath: downloadFileOptions.toFile, title: '', background: false }; loadRequest.downloadFile((this.context as common.BaseContext), downloadConfig) .then((downloadTask: loadRequest.DownloadTask) => { if (downloadTask) { let loadTask: loadRequest.DownloadTask | null = downloadTask; if (downloadFileOptions.hasBeginCallback) { let downloadBeginCallbackResult: DownloadBeginCallbackResult = { jobId: downloadFileOptions.jobId, statusCode: this.ZERO, contentLength: this.ZERO, headers: downloadFileOptions.headers } this.ctx.rnInstance.emitDeviceEvent('DownloadBegin', downloadBeginCallbackResult) } if (downloadFileOptions.hasProgressCallback) { loadTask.on('progress', (receivedSize, totalSize) => { if (totalSize > this.ZERO) { let downloadProgressCallbackResult: DownloadProgressCallbackResult = { jobId: downloadFileOptions.jobId, contentLength: totalSize, bytesWritten: receivedSize } this.ctx.rnInstance.emitDeviceEvent('DownloadProgress', downloadProgressCallbackResult) } }); } loadTask.on('complete', () => { let downloadResult: DownloadResult = { jobId: downloadFileOptions.jobId, statusCode: 200, bytesWritten: this.ZERO } resolve(downloadResult); }) loadTask.on('pause', () => { }) loadTask.on('remove', () => { }) loadTask.on('fail', (err) => { reject(String(err)); }) } else { reject("downloadTask dismiss"); } }).catch((err: BusinessError) => { this.logger.error(`downloadFile error: ${err?.message}`) reject(err); }) }); } // 上传文件 uploadFiles(options: UploadFileOptions): Promise { this.logger.info('uploadFiles') return new Promise(async (resolve, reject) => { let uploadFileOptions: UploadFileOptions = options as UploadFileOptions; // 发送开始事件 if (uploadFileOptions.hasBeginCallback) { let uploadBeginCallbackResult: UploadBeginCallbackResult = { jobId: uploadFileOptions.jobId } this.ctx.rnInstance.emitDeviceEvent('UploadBegin', uploadBeginCallbackResult) } try { if (!uploadFileOptions.files || uploadFileOptions.files.length === 0) { throw new Error('No files to upload'); } const context = this.ctx.uiAbilityContext; const filesArray: loadRequest.File[] = []; const tempFiles: string[] = []; // 存储临时文件路径,用于清理 const fileEntries: Array<{fileItem: UploadFileItem, finalUri: string, tempFilePath?: string}> = []; for (let i = 0; i < uploadFileOptions.files.length; i++) { const fileItem = uploadFileOptions.files[i]; let filePath = fileItem.filepath; // 转换路径格式 if (filePath.startsWith('internal://cache')) { filePath = this.context.cacheDir + filePath.replace('internal://cache', ''); } else if (filePath.startsWith('internal://files')) { filePath = this.context.filesDir + filePath.replace('internal://files', ''); } else if (filePath.startsWith('file://')) { filePath = filePath.replace('file://', ''); } // 检查文件是否存在 const fileExists = fs.accessSync(filePath); if (!fileExists) { throw new Error(`File not found: ${fileItem.filepath}`); } // 检查文件是否已经在 cache 目录 if (filePath.startsWith(this.context.cacheDir)) { // 已经在 cache 目录,直接使用 const finalUri = 'internal://cache' + filePath.replace(this.context.cacheDir, ''); fileEntries.push({ fileItem, finalUri }); } else { // 需要拷贝到 cache 目录 const timestamp = Date.now(); const random = Math.random().toString(36).substr(2, 8); const ext = fileItem.filename.split('.').pop() || 'tmp'; const tempFileName = `upload_${uploadFileOptions.jobId}_${i}_${timestamp}_${random}.${ext}`; const tempFilePath = this.context.cacheDir + '/' + tempFileName; const finalUri = 'internal://cache/' + tempFileName; // 同步拷贝文件 fs.copyFileSync(filePath, tempFilePath); fileEntries.push({ fileItem, finalUri, tempFilePath }); tempFiles.push(tempFilePath); } } // 构建 filesArray for (const entry of fileEntries) { const { fileItem, finalUri } = entry; let type = fileItem.filetype || 'application/octet-stream'; if (!fileItem.filetype) { const ext = fileItem.filename.split('.').pop()?.toLowerCase(); const mimeMap: Record = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'pdf': 'application/pdf', 'txt': 'text/plain', 'json': 'application/json', 'mp4': 'video/mp4', 'mp3': 'audio/mpeg', 'zip': 'application/zip', 'doc': 'application/msword', 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'xls': 'application/vnd.ms-excel', 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'ppt': 'application/vnd.ms-powerpoint', 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }; if (ext && mimeMap[ext]) { type = mimeMap[ext]; } } filesArray.push({ uri: finalUri, name: fileItem.name || fileItem.filename, filename: fileItem.filename, type: type }); } const uploadConfig: loadRequest.UploadConfig = { url: uploadFileOptions.toUrl, method: uploadFileOptions.method || 'POST', header: uploadFileOptions.headers || {}, files: filesArray, data: uploadFileOptions.fields ? Object.entries(uploadFileOptions.fields).map(([k, v]) => ({ name: k, value: String(v) })) : [] }; loadRequest.uploadFile(context, uploadConfig) .then((task: loadRequest.UploadTask) => { let isSettled = false; if (uploadFileOptions.hasProgressCallback) { task.on('progress', (uploadedSize: number, totalSize: number) => { const uploadProgressCallbackResult: UploadProgressCallbackResult = { jobId: uploadFileOptions.jobId, totalBytesExpectedToSend: totalSize, totalBytesSent: uploadedSize }; this.ctx.rnInstance.emitDeviceEvent('UploadProgress', uploadProgressCallbackResult); }); } task.on('complete', (states: Array) => { if (isSettled) return; isSettled = true; task.off('complete'); task.off('fail'); task.off('progress'); // 清理临时文件 this.cleanupTempFiles(tempFiles); const lastState = states[states.length - 1]; let uploadResult: UploadResult = { jobId: uploadFileOptions.jobId, statusCode: lastState.responseCode || 200, headers: uploadFileOptions.headers, body: lastState.message || '' }; resolve(uploadResult); }); task.on('fail', (states: Array) => { if (isSettled) return; isSettled = true; task.off('complete'); task.off('fail'); task.off('progress'); // 清理临时文件 this.cleanupTempFiles(tempFiles); reject(new Error(states[0]?.message || 'Upload failed')); }); }) .catch((err: BusinessError) => { // 清理临时文件 this.cleanupTempFiles(tempFiles); this.logger.error(`uploadFiles error: ${err?.message}`); reject(err); }); } catch (error) { this.logger.error(`uploadFiles error: ${error?.message}`); reject(error); } }); } // 清理临时文件 private cleanupTempFiles(tempFiles: string[]): void { for (const tempFile of tempFiles) { try { fs.unlinkSync(tempFile); this.logger.info(`Cleaned up temp file: ${tempFile}`); } catch (e) { // 文件不存在或其他错误,记录但不抛出 this.logger.warn(`Failed to clean up temp file ${tempFile}: ${e?.message}`); } } } // 常量 getConstants(): Object { let applicationContext = this.context.getApplicationContext(); let result = { // 沙箱路径 FileSandBoxPath: this.context.filesDir, // 缓存路径 FileCachePath: this.context.cacheDir, MainBundlePath: applicationContext.bundleCodeDir, TemporaryDirectoryPath: applicationContext.tempDir, LibraryDirectoryPath: applicationContext.preferencesDir, // 文件 RNFSFileTypeRegular: this.ZERO, // 文件夹 RNFSFileTypeDirectory: this.ONE, } return result; }; // 读取文件内容 readFile(path: string): Promise { this.logger.info('readFile') return new Promise((resolve, reject) => { try { let file = fs.openSync(path); let bufSize = this.FOUR_ZERO_NINE_SIX; let readSize = this.ZERO; let buf = new ArrayBuffer(bufSize); let readOptions: ReadOptions = { offset: readSize, length: bufSize }; let buffers: buffer.Buffer[] = []; let readLen = fs.readSync(file.fd, buf, readOptions); while (readLen > this.ZERO) { readSize += readLen; readOptions.offset = readSize; buffers.push(buffer.from(buf.slice(this.ZERO, readLen))) readLen = fs.readSync(file.fd, buf, readOptions); } fs.closeSync(file); let finalBuf: ArrayBuffer = buffer.concat(buffers).buffer; let base64Helper = new util.Base64Helper; let result = base64Helper.encodeToStringSync(new Uint8Array(finalBuf)); resolve(result); } catch (e) { this.logger.error(`readFile error: ${e?.message}`) reject(e); } }) }; // 判断文件是否存在 exists(path: string): Promise { return new Promise((resolve, reject) => { fs.access(path, (err: BusinessError, result: boolean) => { if (err) { reject('File does not exist'); } else { resolve(result); } }); }) }; // 创建文件 mkdir(path: string): Promise { this.logger.info('mkdir') return new Promise(async (resolve, reject) => { fs.mkdir(path, true, (err: BusinessError) => { if (err) { if (err.code == this.ONE_Three_Nine_ZERO_ZERO_ZERO_ONE_FIVE) { // 文件夹存在 resolve(); } else { reject(`Directory could not be created ${err.message} ${err.code}`); } } else { resolve(); } }) }) } // 写入文件内容 writeFile(path: string, contentStr: string): Promise { return new Promise((resolve, reject) => { // base64 decode 解码 let result = buffer.from(contentStr, this.BASE64); // 读写创建 文件不存在则创建文件 let file = fs.openSync(path, fs.OpenMode.WRITE_ONLY | fs.OpenMode.CREATE | fs.OpenMode.TRUNC); fs.write(file.fd, result.buffer, (err: BusinessError, writeLen: number) => { if (err) { reject('Directory could not be created'); } else { resolve(); } fs.closeSync(file); }); }) }; // 文件内容追加 appendFile(path: string, contentStr: string): Promise { return new Promise((resolve, reject) => { // base64 decode 解码 let result = buffer.from(contentStr, this.BASE64).toString(this.UTF8); // 读写创建 文件内容追加到末尾 let file = fs.openSync(path, fs.OpenMode.WRITE_ONLY | fs.OpenMode.APPEND); fs.write(file.fd, result, (err: BusinessError, writeLen: number) => { if (err) { reject('Directory could not be created'); } else { resolve(); } fs.closeSync(file); }); }) }; // 资源文件内容读取 readFileAssets(path: string): Promise { return new Promise((resolve, reject) => { this.resourceManager.getRawFileContent(path, (err: BusinessError, value: Uint8Array) => { if (err) { this.logger.error(`readFileAssets error: ${err?.message}`) reject(err.message); } else { resolve(this.parsingRawFile(value)); } }); }) } private parsingRawFile(rawFile: Uint8Array): string { let base64 = new util.Base64Helper(); let result = base64.encodeToStringSync(rawFile); return result; } // 将位于from的文件复制到into。 copyFile(from: string, into: string): Promise { return new Promise((resolve, reject) => { let fromResult: string[] = from.split('/'); let intoResult: string[] = into.split('/'); if (fromResult[fromResult.length-this.ONE] === intoResult[intoResult.length-1]) { reject(new Error('The file already exists.')); return; } fs.copyFile(from, into, (err: BusinessError) => { if (err) { reject(err.message); } else { resolve(); } }) }) }; // 删除文件 unlink(path: string): Promise { return new Promise((resolve, reject) => { fs.rmdir(path, (err: BusinessError) => { if (err) { reject('FilePath does not exist'); } else { resolve(); } }); }) } // 文件hash hash(path: string, algorithm: string): Promise { return new Promise((resolve, reject) => { let algorithms: HashMap = new HashMap(); algorithms.set(this.MD5, this.MD5); algorithms.set(this.SHA1, this.SHA1); algorithms.set(this.SHA256, this.SHA256); // algorithm不存在 if (!algorithms.hasKey(algorithm)) { reject('Invalid hash algorithm'); return; } // 判断是否是文件夹 let isDirectory = fs.statSync(path).isDirectory(); if (isDirectory) { reject('file IsDirectory'); return; } // 判断文件是否在 let res = fs.accessSync(path); if (!res) { reject('file not exists'); return; } hash.hash(path, algorithm, (err: BusinessError, result: string) => { if (err) { reject("calculate file hash failed with error message: " + err.message + ", error code: " + err.code); } else { resolve(result.toLocaleLowerCase()); } }) }) } // 移动文件 moveFile(filepath: string, destPath: string): Promise { return new Promise((resolve, reject) => { fs.moveFile(filepath, destPath, this.ZERO, (err: BusinessError) => { if (err) { reject('move file failed with error message: ' + err.message + ', error code: ' + err.code); } else { resolve(); } }) }) } // 文件内容部分读 read(path: string, length: number, position: number): Promise { return new Promise((resolve, reject) => { let readTextOption: ReadTextOptions = { offset: position, length: length, encoding: this.UTF_8 }; fs.readText(path, readTextOption, (err: BusinessError, str: string) => { if (err) { reject('readText failed with error message: ' + err.message + ', error code: ' + err.code); } else { let result = buffer.from(str, this.UTF8).toString(this.BASE64); resolve(result); } }); }) } // 文件内容从某位置写 write(filepath: string, contents: string, position: number): Promise { return new Promise((resolve, reject) => { let result = buffer.from(contents, this.BASE64).toString(this.UTF8); let file = fs.openSync(filepath, fs.OpenMode.WRITE_ONLY | fs.OpenMode.CREATE); let writeOption: WriteOptions = { offset: fs.statSync(filepath).size }; fs.write(file.fd, result, writeOption, (err: BusinessError, writeLen: number) => { if (err) { reject('write data to file failed with error message:' + err.message + ', error code: ' + err.code); } else { resolve(); } fs.closeSync(file); }); }) } touch(filePath: string, mtime?: number, ctime?: number): Promise { return new Promise((resolve, reject) => { // 判断是否是文件夹 let isDirectory = fs.statSync(filePath).isDirectory(); if (isDirectory) { reject('file IsDirectory'); return; } // 判断文件是否在 let res = fs.accessSync(filePath); if (!res) { reject('No such file or directory'); return; } if (mtime) { try { fs.utimes(filePath, mtime); resolve(true) } catch (err) { resolve(err.message) } } else { resolve(false) } }) } // 获取文件详细属性信息 stat(filepath: string): Promise { return new Promise((resolve, reject) => { let statResult: StatResult = { ctime: -this.ONE, mtime: -this.ONE, size: -this.ONE, mode: -this.ONE, originalFilepath: '', type: -this.ONE }; // 判断文件是否在 let res = fs.accessSync(filepath); if (!res) { reject('file not exists'); return; } fs.stat(filepath, (err: BusinessError, stat: fs.Stat) => { if (err) { this.logger.error(TAG, `error message: ` + err.message + ', error code: ' + err.code); } else { statResult.ctime = stat.ctime; statResult.mtime = stat.mtime; statResult.size = stat.size; statResult.mode = stat.mode; statResult.originalFilepath = filepath; statResult.type = stat.isDirectory() ? this.ONE : this.ZERO; this.logger.info(TAG, 'file statResult: ' + JSON.stringify(statResult)); resolve(statResult); } }); }) } // 处理异常警告 没有实际业务 addListener(eventName: string): void { } // 处理异常警告 没有实际业务 removeListeners(count: number): void { } }