Skip to content

Commit 36f010b

Browse files
committed
Added some new features.
1 parent ed71430 commit 36f010b

5 files changed

Lines changed: 232 additions & 19 deletions

File tree

.github/workflows/build-apk.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Build APK
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
workflow_dispatch:
8+
release:
9+
types: [ published ]
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v4
18+
19+
- name: Set up JDK 17
20+
uses: actions/setup-java@v4
21+
with:
22+
distribution: temurin
23+
java-version: '17'
24+
25+
- name: Set up Android SDK
26+
uses: android-actions/setup-android@v3
27+
28+
- name: Set up Gradle
29+
uses: gradle/actions/setup-gradle@v4
30+
with:
31+
gradle-version: '8.7'
32+
33+
- name: Build APK
34+
run: gradle assembleDebug --no-daemon --stacktrace
35+
36+
- name: Upload APK
37+
uses: actions/upload-artifact@v4
38+
with:
39+
name: arklight-viewer-apk
40+
path: app/build/outputs/apk/debug/*.apk
41+
42+
- name: Attach APK to GitHub Release
43+
if: github.event_name == 'release'
44+
uses: softprops/action-gh-release@v2
45+
with:
46+
files: app/build/outputs/apk/debug/*.apk

app/src/main/java/com/arklight/viewer/ArkBundle.kt

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.arklight.viewer
22

3+
import android.content.Context
34
import java.io.File
45
import java.io.FileOutputStream
56
import 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}")

app/src/main/java/com/arklight/viewer/MainActivity.kt

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import androidx.webkit.WebViewAssetLoader
2121
import kotlinx.coroutines.Dispatchers
2222
import kotlinx.coroutines.launch
2323
import kotlinx.coroutines.withContext
24+
import java.io.ByteArrayInputStream
2425
import java.io.File
26+
import android.webkit.MimeTypeMap
2527

2628
/**
2729
* Origin strategy (see ARCHITECTURE.md, "Origin stability"):
@@ -54,6 +56,13 @@ class MainActivity : AppCompatActivity() {
5456
private var siteReady = false
5557
private var showingFullSite = false
5658

59+
// Which backing the currently-open bundle's full site is using --
60+
// RAM when there was headroom for it, disk otherwise. Whatever it
61+
// is, it gets flushed (RAM reference dropped / disk dir deleted)
62+
// as soon as we're done with it: right before extracting the next
63+
// bundle, and when the activity is destroyed. See ArkBundle.flush.
64+
private var currentBacking: ArkBundle.SiteBacking? = null
65+
5766
private val openDocument =
5867
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
5968
uri?.let { openArkFile(it) }
@@ -75,7 +84,7 @@ class MainActivity : AppCompatActivity() {
7584

7685
assetLoader = WebViewAssetLoader.Builder()
7786
.addPathHandler("/entry/", WebViewAssetLoader.InternalStoragePathHandler(this, entryDir))
78-
.addPathHandler("/site/", WebViewAssetLoader.InternalStoragePathHandler(this, siteDir))
87+
.addPathHandler("/site/", SitePathHandler())
7988
.build()
8089

8190
webView.webViewClient = object : WebViewClient() {
@@ -97,6 +106,49 @@ class MainActivity : AppCompatActivity() {
97106
handleIntent(intent)
98107
}
99108

109+
override fun onDestroy() {
110+
super.onDestroy()
111+
// App's actually done with the current site now -- release
112+
// whichever backing it was using (RAM reference dropped for
113+
// GC, or the disk fallback directory deleted outright).
114+
flushCurrentSite()
115+
}
116+
117+
private fun flushCurrentSite() {
118+
ArkBundle.flush(currentBacking)
119+
currentBacking = null
120+
siteReady = false
121+
}
122+
123+
/**
124+
* Serves the currently-open site's files from whichever backing
125+
* [currentBacking] holds. RAM-backed sites are served straight out
126+
* of the in-memory map; disk-backed ones delegate to a plain
127+
* [WebViewAssetLoader.InternalStoragePathHandler] pointed at the
128+
* fallback directory. Either way callers just see `/site/...`
129+
* resolve under the same stable origin -- see the class doc above.
130+
*/
131+
private inner class SitePathHandler : WebViewAssetLoader.PathHandler {
132+
override fun handle(path: String): WebResourceResponse? {
133+
return when (val backing = currentBacking) {
134+
is ArkBundle.SiteBacking.Ram -> {
135+
val key = if (path.isEmpty()) "index.html" else path
136+
val bytes = backing.files[key] ?: return null
137+
WebResourceResponse(mimeTypeFor(key), null, ByteArrayInputStream(bytes))
138+
}
139+
is ArkBundle.SiteBacking.Disk ->
140+
WebViewAssetLoader.InternalStoragePathHandler(this@MainActivity, backing.dir)
141+
.handle(path)
142+
null -> null
143+
}
144+
}
145+
}
146+
147+
private fun mimeTypeFor(path: String): String {
148+
val ext = path.substringAfterLast('.', "")
149+
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "application/octet-stream"
150+
}
151+
100152
private fun handleIntent(intent: Intent?) {
101153
val uri: Uri? = intent?.data
102154
if (uri != null) openArkFile(uri) else showWelcomeScreen()
@@ -121,6 +173,11 @@ class MainActivity : AppCompatActivity() {
121173
private fun openArkFile(uri: Uri) {
122174
lifecycleScope.launch {
123175
progress.visibility = ProgressBar.VISIBLE
176+
177+
// Done with whatever was open before -- release its RAM
178+
// or disk backing before we start pulling in the next one.
179+
flushCurrentSite()
180+
124181
val bytes = withContext(Dispatchers.IO) {
125182
runCatching { contentResolver.openInputStream(uri)?.use { it.readBytes() } }
126183
.getOrNull()
@@ -152,13 +209,18 @@ class MainActivity : AppCompatActivity() {
152209

153210
private suspend fun tryExtractFullSite(archiveBytes: ByteArray, passphrase: String?) {
154211
val result = withContext(Dispatchers.IO) {
155-
ArkBundle.unsealAndExtract(archiveBytes, siteDir, passphrase)
212+
ArkBundle.unsealAndExtract(archiveBytes, siteDir, passphrase, applicationContext)
156213
}
157214
when (result) {
158215
is ArkBundle.ExtractResult.Success -> {
216+
currentBacking = result.backing
159217
siteReady = true
160218
invalidateOptionsMenu()
161-
toast("Full site ready — see \u22EE menu \u2192 Browse full site.")
219+
val where = when (result.backing) {
220+
is ArkBundle.SiteBacking.Ram -> "in memory"
221+
is ArkBundle.SiteBacking.Disk -> "on disk"
222+
}
223+
toast("Full site ready ($where) — see \u22EE menu \u2192 Browse full site.")
162224
}
163225
is ArkBundle.ExtractResult.NeedsPassphrase -> {
164226
if (passphrase == null) {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.arklight.viewer
2+
3+
import android.app.ActivityManager
4+
import android.content.Context
5+
6+
/**
7+
* Decides whether it's safe to hold an extracted site's files in RAM
8+
* instead of writing them to disk. Conservative by design: any
9+
* uncertainty resolves to "no" (disk), since the cost of guessing
10+
* wrong on RAM is a possible low-memory kill, while the cost of
11+
* guessing wrong on disk is just a slower, disk-backed WebView load.
12+
*/
13+
object MemoryGuard {
14+
15+
/** Extra headroom kept above the system's own low-memory threshold. */
16+
private const val SAFETY_MARGIN_BYTES = 32L * 1024 * 1024 // 32MB
17+
18+
/**
19+
* True if the device currently has enough free RAM to comfortably
20+
* absorb [requiredBytes] more resident data without approaching
21+
* the point where Android would start killing background
22+
* processes for memory.
23+
*/
24+
fun hasRamHeadroom(context: Context, requiredBytes: Long): Boolean {
25+
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
26+
?: return false
27+
28+
val info = ActivityManager.MemoryInfo()
29+
am.getMemoryInfo(info)
30+
31+
// The system is already telling us it's tight -- don't add to it.
32+
if (info.lowMemory) return false
33+
34+
val freeAboveThreshold = info.availMem - info.threshold - SAFETY_MARGIN_BYTES
35+
return freeAboveThreshold > requiredBytes
36+
}
37+
}

app/src/main/res/menu/main_menu.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<menu xmlns:android="http://schemas.android.com/apk/res/android"
3-
xmlns:app="http://schemas.android.com/apk/res/app">
3+
xmlns:app="http://schemas.android.com/apk/res-auto">
44

55
<item
66
android:id="@+id/action_open"

0 commit comments

Comments
 (0)