1/* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16import JSZip from 'jszip'; 17import {ArrayUtils} from './array_utils'; 18import {FunctionUtils, OnProgressUpdateType} from './function_utils'; 19 20/** 21 * Type definition for a callback function that is called when a file is unzipped. 22 * 23 * @param file The unzipped file. 24 * @param parentArchive The parent archive file, if any. 25 */ 26export type OnFile = (file: File, parentArchive: File | undefined) => void; 27 28/** 29 * Utility class for file operations. 30 */ 31export class FileUtils { 32 //allow: letters/numbers/underscores with delimiters . - # (except at start and end) 33 static readonly DOWNLOAD_FILENAME_REGEX = /^\w+?((|#|-|\.)\w+)+$/; 34 static readonly ILLEGAL_FILENAME_CHARACTERS_REGEX = /[^A-Za-z0-9-#._]/g; 35 36 /** 37 * Extracts the file extension from a filename. 38 * 39 * @param filename The filename to extract the extension from. 40 * @return The file extension, or undefined if there is no extension. 41 */ 42 static getFileExtension(filename: string): string | undefined { 43 const lastDot = filename.lastIndexOf('.'); 44 if (lastDot === -1) { 45 return undefined; 46 } 47 return filename.slice(lastDot + 1); 48 } 49 50 /** 51 * Removes the directory from a filename. 52 * 53 * @param name The filename to remove the directory from. 54 * @return The filename without the directory. 55 */ 56 static removeDirFromFileName(name: string): string { 57 if (name.includes('/')) { 58 const startIndex = name.lastIndexOf('/') + 1; 59 return name.slice(startIndex); 60 } else { 61 return name; 62 } 63 } 64 65 /** 66 * Removes the extension from a filename. 67 * 68 * @param name The filename to remove the extension from. 69 * @return The filename without the extension. 70 */ 71 static removeExtensionFromFilename(name: string): string { 72 if (name.includes('.')) { 73 const lastIndex = name.lastIndexOf('.'); 74 return name.slice(0, lastIndex); 75 } else { 76 return name; 77 } 78 } 79 80 /** 81 * Creates a ZIP archive from a list of files. 82 * 83 * @param files The list of files to archive. 84 * @param progressCallback An optional callback function that will be called 85 * as the archive is created, passing a number between 0 and 1 as an 86 * argument, representing the progress of the operation. 87 * @return A Promise that resolves to the ZIP archive Blob. 88 */ 89 static async createZipArchive( 90 files: File[], 91 progressCallback?: OnProgressUpdateType, 92 ): Promise<Blob> { 93 const zip = new JSZip(); 94 for (let i = 0; i < files.length; i++) { 95 const file = files[i]; 96 const blob = await file.arrayBuffer(); 97 zip.file(file.name, blob); 98 if (progressCallback) progressCallback((i + 1) / files.length); 99 } 100 return await zip.generateAsync({type: 'blob'}); 101 } 102 103 /** 104 * Unzips a ZIP archive file. 105 * 106 * @param file The ZIP archive file to unzip. 107 * @param onProgressUpdate An optional callback function that will be called 108 * as the archive is unzipped, passing a number between 0 and 1 as an 109 * argument, representing the progress of the operation. 110 * @return A Promise that resolves to an array of the unzipped files. 111 */ 112 static async unzipFile( 113 file: Blob, 114 onProgressUpdate: OnProgressUpdateType = FunctionUtils.DO_NOTHING, 115 ): Promise<File[]> { 116 const unzippedFiles: File[] = []; 117 const zip = new JSZip(); 118 const content = await zip.loadAsync(file); 119 120 const filenames = Object.keys(content.files); 121 for (const [index, filename] of filenames.entries()) { 122 const file = content.files[filename]; 123 if (file.dir) { 124 // Ignore directories 125 continue; 126 } else { 127 const fileBlob = await file.async('blob'); 128 const unzippedFile = new File([fileBlob], filename); 129 if (await FileUtils.isZipFile(unzippedFile)) { 130 unzippedFiles.push(...(await FileUtils.unzipFile(fileBlob))); 131 } else { 132 unzippedFiles.push(unzippedFile); 133 } 134 } 135 136 onProgressUpdate((100 * (index + 1)) / filenames.length); 137 } 138 139 return unzippedFiles; 140 } 141 142 /** 143 * Decompresses a GZIP file. 144 * 145 * @param file The GZIP file to decompress. 146 * @return A Promise that resolves to the decompressed file. 147 */ 148 static async decompressGZipFile(file: File): Promise<File> { 149 const decompressionStream = new (window as any).DecompressionStream('gzip'); 150 const decompressedStream = file.stream().pipeThrough(decompressionStream); 151 const fileBlob = await new Response(decompressedStream).blob(); 152 const filename = 153 FileUtils.getFileExtension(file.name) === 'gz' 154 ? FileUtils.removeExtensionFromFilename(file.name) 155 : file.name; 156 return new File([fileBlob], filename); 157 } 158 159 /** 160 * Checks if a file is a ZIP file. 161 * 162 * @param file The file to check. 163 * @return A Promise that resolves to true if the file is a ZIP file, and 164 * false otherwise. 165 */ 166 static async isZipFile(file: File): Promise<boolean> { 167 return FileUtils.isMatchForMagicNumber(file, FileUtils.PK_ZIP_MAGIC_NUMBER); 168 } 169 170 /** 171 * Checks if a file is a GZIP file. 172 * 173 * @param file The file to check. 174 * @return A Promise that resolves to true if the file is a GZIP file, and 175 * false otherwise. 176 */ 177 static async isGZipFile(file: File): Promise<boolean> { 178 return FileUtils.isMatchForMagicNumber(file, FileUtils.GZIP_MAGIC_NUMBER); 179 } 180 181 /** 182 * Checks if a file matches a given magic number. 183 * 184 * @param file The file to check. 185 * @param magicNumber The magic number to match. 186 * @return A Promise that resolves to true if the file matches the magic 187 * number, and false otherwise. 188 */ 189 private static async isMatchForMagicNumber( 190 file: File, 191 magicNumber: number[], 192 ): Promise<boolean> { 193 const bufferStart = new Uint8Array((await file.arrayBuffer()).slice(0, 2)); 194 return ArrayUtils.equal(bufferStart, magicNumber); 195 } 196 197 private static readonly GZIP_MAGIC_NUMBER = [0x1f, 0x8b]; 198 private static readonly PK_ZIP_MAGIC_NUMBER = [0x50, 0x4b]; 199} 200