11package com.arklight.viewer
22
3+ import android.content.Context
34import java.io.File
45import java.io.FileOutputStream
56import java.util.zip.ZipEntry
@@ -48,27 +49,40 @@ object ArkBundle {
4849 File (entryDir, " index.html" ).writeText(entryHtml, Charsets .UTF_8 )
4950 }
5051
52+ /* *
53+ * Where an extracted site's files currently live. [Ram] is the
54+ * preferred backing — nothing touches disk, and [flush] just drops
55+ * the reference for GC. [Disk] is the fallback used when
56+ * [MemoryGuard] says RAM is too tight, backed by a fixed directory
57+ * under the app's own data folder (`cacheDir/ark_current/site` —
58+ * see [MainActivity]) so `WebViewAssetLoader`'s origin stays
59+ * constant across bundles.
60+ */
61+ sealed class SiteBacking {
62+ data class Ram (val files : Map <String , ByteArray >) : SiteBacking()
63+ data class Disk (val dir : File ) : SiteBacking()
64+ }
65+
5166 sealed class ExtractResult {
52- data class Success (val dir : File ) : ExtractResult()
67+ data class Success (val backing : SiteBacking ) : ExtractResult()
5368 object NeedsPassphrase : ExtractResult()
5469 data class Failed (val reason : String ) : ExtractResult()
5570 }
5671
5772 /* *
58- * Unseals (if needed) and unzips the archive half into [outDir],
59- * which the caller points at a **fixed path** (e.g.
60- * `cacheDir/ark_current/site`) rather than a per-bundle hash
61- * directory. That's deliberate: `WebViewAssetLoader` binds a path
62- * handler to a directory *path* once, at `Builder` time — keeping
63- * that path constant across every opened bundle means the loader
64- * (and therefore the served origin, `https://appassets.
65- * androidplatform.net/site/`) never changes, which is what makes
66- * origin-scoped storage (`localStorage`, IndexedDB, cookies) behave
67- * consistently across different bundles instead of being silently
68- * partitioned per bundle. See ARCHITECTURE.md, "Origin stability."
69- * [outDir] is cleared before each extraction.
73+ * Unseals (if needed) and unzips the archive half, preferring to
74+ * hold the result entirely in RAM ([SiteBacking.Ram]) and only
75+ * falling back to writing it under [outDir] ([SiteBacking.Disk])
76+ * when [MemoryGuard] reports the device doesn't have comfortable
77+ * headroom for that. [outDir] is only touched in the fallback
78+ * case, and is cleared before each extraction into it.
7079 */
71- fun unsealAndExtract (archiveBytes : ByteArray , outDir : File , passphrase : String? ): ExtractResult {
80+ fun unsealAndExtract (
81+ archiveBytes : ByteArray ,
82+ outDir : File ,
83+ passphrase : String? ,
84+ context : Context
85+ ): ExtractResult {
7286 if (archiveBytes.isEmpty()) {
7387 return ExtractResult .Failed (" no archive half present (entry-page-only bundle)" )
7488 }
@@ -85,6 +99,60 @@ object ArkBundle {
8599 archiveBytes
86100 }
87101
102+ // Uncompressed HTML/CSS/JS/JSON typically runs 3-5x the
103+ // compressed size; budget 6x so a bad guess only ever costs an
104+ // unnecessary disk write, never a memory squeeze -- the actual
105+ // safety margin is enforced inside MemoryGuard itself.
106+ val estimatedUncompressed = zipBytes.size.toLong() * 6
107+
108+ return if (MemoryGuard .hasRamHeadroom(context, estimatedUncompressed)) {
109+ extractToMemory(zipBytes)
110+ } else {
111+ extractToDisk(zipBytes, outDir)
112+ }
113+ }
114+
115+ /* *
116+ * Releases whichever backing a site is currently using. RAM just
117+ * drops the reference for GC; disk is deleted outright.
118+ */
119+ fun flush (backing : SiteBacking ? ) {
120+ if (backing is SiteBacking .Disk ) {
121+ backing.dir.deleteRecursively()
122+ }
123+ // Ram case: caller drops its reference to `backing`; there's
124+ // nothing else holding the byte arrays, so they're GC-eligible
125+ // immediately.
126+ }
127+
128+ private fun extractToMemory (zipBytes : ByteArray ): ExtractResult {
129+ val files = mutableMapOf<String , ByteArray >()
130+ return try {
131+ ZipInputStream (zipBytes.inputStream()).use { zis ->
132+ var entry: ZipEntry ? = zis.nextEntry
133+ while (entry != null ) {
134+ if (! entry.isDirectory) {
135+ val name = entry.name
136+ // Same zip-slip concern as the disk path: a
137+ // "../" entry isn't a legitimate site-relative
138+ // path even though it can't escape a directory
139+ // when there's no directory to escape.
140+ if (name.contains(" .." )) {
141+ throw SecurityException (" Unsafe zip entry path: $name " )
142+ }
143+ files[name] = zis.readBytes()
144+ }
145+ zis.closeEntry()
146+ entry = zis.nextEntry
147+ }
148+ }
149+ ExtractResult .Success (SiteBacking .Ram (files))
150+ } catch (e: Exception ) {
151+ ExtractResult .Failed (" bad zip once unsealed: ${e.message} " )
152+ }
153+ }
154+
155+ private fun extractToDisk (zipBytes : ByteArray , outDir : File ): ExtractResult {
88156 outDir.deleteRecursively()
89157 outDir.mkdirs()
90158
@@ -107,7 +175,7 @@ object ArkBundle {
107175 entry = zis.nextEntry
108176 }
109177 }
110- ExtractResult .Success (outDir)
178+ ExtractResult .Success (SiteBacking . Disk ( outDir) )
111179 } catch (e: Exception ) {
112180 outDir.deleteRecursively()
113181 ExtractResult .Failed (" bad zip once unsealed: ${e.message} " )
0 commit comments