Skip to content

feat: implement native unzip for plugin installation performance impr…#2367

Closed
RohitKushvaha01 wants to merge 1 commit into
Acode-Foundation:mainfrom
RohitKushvaha01:feature/native-unzip
Closed

feat: implement native unzip for plugin installation performance impr…#2367
RohitKushvaha01 wants to merge 1 commit into
Acode-Foundation:mainfrom
RohitKushvaha01:feature/native-unzip

Conversation

@RohitKushvaha01

@RohitKushvaha01 RohitKushvaha01 commented Jun 23, 2026

Copy link
Copy Markdown
Member

No description provided.

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the JavaScript-based, batched per-file extraction (JSZip + writeFile loop) with a single native Android unzip call, routing the downloaded zip through a temp file in CACHE_STORAGE and extracting it via a new System.unzip Cordova plugin method. JSZip is still used for manifest reading and validation before the native step runs.

  • New native unzip in System.java: Runs on the background thread, handles file:// and content URIs, and includes zip-slip protection via canonical-path comparison. A startsWith("\\\\") guard is unreachable after the preceding backslash-normalization step.
  • installPlugin.js state tracking: Install-state entries are now stored as the literal "1" rather than a SHA-256 content hash, which removes the content-based delta-update logic; deleteRedundantFiles is unaffected since it only tests key presence. The zip ArrayBuffer is held in memory by JSZip and simultaneously written to disk as a temp file, so peak memory is not reduced relative to before.

Confidence Score: 4/5

The core extraction path is functionally correct and the native unzip includes zip-slip protection; the main concerns are a small unreachable check in the Java code and the silent change in install-state semantics.

The happy-path install flow works correctly end-to-end. The issues found are a dead branch in the Java security check (protected by an earlier guard so no real exposure), doubled memory usage due to JSZip parsing before the temp file is written, and a shift from SHA-256 hashes to constant "1" values in the persisted install state. None of these cause broken installs or data loss, but the state-semantics change could surprise future contributors relying on the stored values.

src/lib/installPlugin.js deserves a second look — specifically the memory profile (JSZip in-memory + raw bytes on disk simultaneously) and the new install-state value format.

Important Files Changed

Filename Overview
src/lib/installPlugin.js Replaces JSZip-based per-file extraction with a native unzip call via a temp file in CACHE_STORAGE; JSZip is still used for manifest parsing and state population, so the zip occupies memory twice. Install-state values are now stored as "1" instead of SHA-256 hashes, losing content-based delta tracking.
src/plugins/system/android/com/foxdebug/system/System.java Adds native unzip method that runs on the background thread; includes zip-slip protection via canonical-path comparison. Contains a dead startsWith("\\\\") check that is unreachable after the backslash-normalization step.
src/plugins/system/system.d.ts Adds TypeScript declaration for the new unzip method; signature matches the Cordova plugin implementation.
src/plugins/system/www/plugin.js Exposes unzip as a Cordova bridge call forwarding to the System plugin; straightforward addition consistent with existing plugin methods.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant JS as installPlugin.js
    participant JSZip
    participant FS as fsOperation (CACHE_STORAGE)
    participant Cordova as Cordova Bridge
    participant Java as System.java (unzip)
    participant PluginDir as Plugin Directory

    JS->>FS: readFile(pluginUrl) → ArrayBuffer
    JS->>JSZip: loadAsync(plugin)
    JSZip-->>JS: parsed zip (manifest + file list)
    JS->>JS: "validate & patch pluginJson"
    JS->>FS: createFile(tempZipName, plugin)
    Note over FS: Raw zip written to CACHE_STORAGE
    JS->>Cordova: system.unzip(tempZipFile, pluginDir)
    Cordova->>Java: unzip(zipPath, destPath)
    Java->>Java: ZipSlip check (canonical path)
    Java->>PluginDir: extract all entries
    Java-->>Cordova: callback.success()
    Cordova-->>JS: resolve()
    JS->>PluginDir: writeFile(plugin.json, patched JSON)
    JS->>JS: populate state.updatedStore from zip entries
    JS->>JS: state.save()
    JS->>PluginDir: deleteRedundantFiles()
    JS->>FS: delete(tempZipFile)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant JS as installPlugin.js
    participant JSZip
    participant FS as fsOperation (CACHE_STORAGE)
    participant Cordova as Cordova Bridge
    participant Java as System.java (unzip)
    participant PluginDir as Plugin Directory

    JS->>FS: readFile(pluginUrl) → ArrayBuffer
    JS->>JSZip: loadAsync(plugin)
    JSZip-->>JS: parsed zip (manifest + file list)
    JS->>JS: "validate & patch pluginJson"
    JS->>FS: createFile(tempZipName, plugin)
    Note over FS: Raw zip written to CACHE_STORAGE
    JS->>Cordova: system.unzip(tempZipFile, pluginDir)
    Cordova->>Java: unzip(zipPath, destPath)
    Java->>Java: ZipSlip check (canonical path)
    Java->>PluginDir: extract all entries
    Java-->>Cordova: callback.success()
    Cordova-->>JS: resolve()
    JS->>PluginDir: writeFile(plugin.json, patched JSON)
    JS->>JS: populate state.updatedStore from zip entries
    JS->>JS: state.save()
    JS->>PluginDir: deleteRedundantFiles()
    JS->>FS: delete(tempZipFile)
Loading

Comments Outside Diff (1)

  1. src/lib/installPlugin.js, line 104-185 (link)

    P2 Zip still loaded into JSZip memory AND written to disk

    plugin (the raw zip ArrayBuffer) is parsed by new JSZip() and held in memory for manifest validation and state-population, then the same binary is also written to CACHE_STORAGE as a temp file for native extraction. For large plugins this means the zip occupies memory simultaneously in two forms (parsed JSZip object tree + raw bytes on disk). If the goal is to reduce peak memory, it would be worth evaluating whether the manifest fields could be read from the raw bytes without a full JSZip parse, or whether the temp file could be saved first and the manifest read from there via native extraction.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "feat: implement native unzip for plugin ..." | Re-trigger Greptile

name = name.replace('\\', '/');

// Skip unsafe absolute paths
if (name.startsWith("/") || name.contains("://") || name.startsWith("\\\\") || (name.length() > 1 && name.charAt(1) == ':')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dead check: startsWith("\\\\") is unreachable

name has already been fully normalised by name.replace('\\', '/') on the line above, so no backslashes can remain. The name.startsWith("\\\\") branch will never be true. A UNC path like \\server\share becomes //server/share after the replace and is already blocked by the earlier name.startsWith("/") guard, so the protection still holds — but the second check is dead code and can mislead readers into thinking it provides independent coverage.

Comment thread src/lib/installPlugin.js
Comment on lines +204 to 220
for (const file of files) {
const entry = zip.files[file];
let correctFile = file.replace(/\\/g, "/");
const isDirEntry = entry.dir || correctFile.endsWith("/");

await fsOperation(fileUrl).writeFile(data);
} catch (error) {
console.error(`Error processing file ${file}:`, error);
if (isUnsafeAbsolutePath(file)) {
ignoredUnsafeEntries.add(file);
continue;
}
}

// Process in batches
for (let i = 0; i < files.length; i += limit) {
const batch = files.slice(i, i + limit);
await Promise.allSettled(batch.map(processFile));
correctFile = sanitizeZipPath(correctFile, isDirEntry);
if (!correctFile) continue;

// Allow UI thread to breathe
await new Promise((r) => setTimeout(r, 0));
if (!isDirEntry) {
state.updatedStore[correctFile.toLowerCase()] = "1";
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Install-state values changed from content hashes to a constant "1"

The old code stored a SHA-256 content hash in updatedStore via state.isUpdated(). The new loop writes "1" for every non-directory entry. After state.save() this persists to disk, so any future code path that reads the stored value expecting a real hash (e.g. a delta-update check in a future install) will instead see "1" and treat everything as changed. The existing state.exists() path works fine since it only tests key presence, but the semantics of the state file have silently shifted from "hash of last-installed content" to "was ever installed".

@RohitKushvaha01 RohitKushvaha01 marked this pull request as draft June 23, 2026 08:34
@RohitKushvaha01

Copy link
Copy Markdown
Member Author

Closed in favour of #2376

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant