• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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