Replace bundled ROM extraction with runtime import/download flow
Commodore ROMs are copyrighted and can no longer ship inside the APK. MainActivity now detects missing ROMs on first launch and blocks startup with a dialog offering two paths: pick files via the system file picker, or download the official VICE 3.8 tarball and extract the four ROMs from it client-side (minimal USTAR reader, no extra deps). - Drop the extractViceRoms Gradle task and asset bundling; add res/extract_roms.sh for local sideloading during development instead - gitignore keystores/keystore.properties ahead of a signed release - Move architecture.md into docs/, refresh it for the screen-mirroring and watch-input additions, and add accompanying Mermaid diagrams - Add docs/debugging.md and docs/publish.md (Play Store release notes, ROM-import compliance rationale)
This commit is contained in:
@@ -13,3 +13,6 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
|
||||
# ROM files extracted from the VICE tarball by res/extract_roms.sh
|
||||
/res/roms
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/build
|
||||
|
||||
# ROM files extracted from the VICE tarball at build time (see extractViceRoms task)
|
||||
# Copyrighted Commodore ROMs are never bundled — users import their own at
|
||||
# runtime (MainActivity.showRomImportDialog). Ignored here as a safety net.
|
||||
/src/main/assets/kernal
|
||||
/src/main/assets/basic
|
||||
/src/main/assets/chargen
|
||||
/src/main/assets/1541
|
||||
/src/main/assets/1541
|
||||
|
||||
@@ -130,47 +130,12 @@ tasks.whenTaskAdded {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ROM extraction task
|
||||
//
|
||||
// Extracts C64 and drive ROMs from the bundled VICE tarball into the Android
|
||||
// assets directory so they are packaged into the APK automatically.
|
||||
// Output files are gitignored — the tarball is the single source of truth.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
val assetsDir = layout.projectDirectory.dir("src/main/assets")
|
||||
|
||||
tasks.register<Copy>("extractViceRoms") {
|
||||
group = "build"
|
||||
description = "Extract C64 and 1541 ROMs from the VICE tarball into assets/"
|
||||
|
||||
from(tarTree(viceTarball.asFile)) {
|
||||
include("vice-3.8/data/C64/kernal-901227-03.bin")
|
||||
include("vice-3.8/data/C64/basic-901226-01.bin")
|
||||
include("vice-3.8/data/C64/chargen-901225-01.bin")
|
||||
include("vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin")
|
||||
eachFile {
|
||||
// Flatten the tarball directory structure: place each ROM directly
|
||||
// in assets/ with its short name rather than the versioned filename.
|
||||
relativePath = RelativePath(true, when (name) {
|
||||
"kernal-901227-03.bin" -> "kernal"
|
||||
"basic-901226-01.bin" -> "basic"
|
||||
"chargen-901225-01.bin" -> "chargen"
|
||||
"dos1541-325302-01+901229-05.bin" -> "1541"
|
||||
else -> name
|
||||
})
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
}
|
||||
into(assetsDir)
|
||||
}
|
||||
|
||||
// Run extractViceRoms before assets are merged into the APK.
|
||||
tasks.whenTaskAdded {
|
||||
if (name.startsWith("merge") && name.endsWith("Assets")) {
|
||||
dependsOn("extractViceRoms")
|
||||
}
|
||||
}
|
||||
// Note: C64/1541 ROMs are intentionally NOT bundled into the APK — they are
|
||||
// copyrighted Commodore firmware. The app prompts the user to import their
|
||||
// own ROM dump at runtime instead (see MainActivity.showRomImportDialog()).
|
||||
// res/extract_roms.sh remains available for extracting ROMs from the VICE
|
||||
// tarball locally (e.g. to sideload onto a test device), but nothing in this
|
||||
// build copies them into assets/ or the APK.
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.activity.ktx)
|
||||
|
||||
+239
-13
@@ -31,9 +31,15 @@ import androidx.drawerlayout.widget.DrawerLayout
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.GZIPInputStream
|
||||
|
||||
private const val PORT = 8888
|
||||
private const val TAG = "SuM"
|
||||
@@ -235,7 +241,14 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun initEmulator() {
|
||||
val assetDir = getExternalFilesDir(null) ?: filesDir
|
||||
assetDir.mkdirs()
|
||||
copyBundledRoms(assetDir)
|
||||
if (missingRoms(assetDir).isNotEmpty()) {
|
||||
showRomImportDialog(assetDir)
|
||||
return
|
||||
}
|
||||
startEmulator(assetDir)
|
||||
}
|
||||
|
||||
private fun startEmulator(assetDir: File) {
|
||||
val romDir = assetDir.absolutePath
|
||||
val ok = engine.initEmulator(romDir)
|
||||
Log.d(TAG, "initEmulator=$ok romDir=$romDir")
|
||||
@@ -262,22 +275,213 @@ class MainActivity : AppCompatActivity() {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- bundled ROM extraction ---------------------------------------------
|
||||
// ---- ROM import -----------------------------------------------------
|
||||
|
||||
private val bundledRoms = listOf("kernal", "basic", "chargen", "1541")
|
||||
// The companion app does not ship Commodore ROMs (they're copyrighted) —
|
||||
// the user must supply their own dump on first run. See docs/publish.md §0.
|
||||
private val requiredRoms = listOf("kernal", "basic", "chargen", "1541")
|
||||
|
||||
private fun copyBundledRoms(destDir: File) {
|
||||
for (name in bundledRoms) {
|
||||
val dest = File(destDir, name)
|
||||
if (dest.exists()) continue
|
||||
try {
|
||||
assets.open(name).use { input ->
|
||||
dest.outputStream().use { output -> input.copyTo(output) }
|
||||
private fun missingRoms(dir: File): List<String> =
|
||||
requiredRoms.filter { !File(dir, it).exists() }
|
||||
|
||||
// Identifies which canonical ROM name a picked file corresponds to, based
|
||||
// on the versioned filenames VICE ships (e.g. "kernal-901227-03.bin") as
|
||||
// well as plain names a user might have renamed a dump to.
|
||||
private fun romNameForFile(fileName: String): String? {
|
||||
val lower = fileName.lowercase()
|
||||
return when {
|
||||
"kernal" in lower -> "kernal"
|
||||
"chargen" in lower -> "chargen"
|
||||
"basic" in lower -> "basic"
|
||||
"1541" in lower -> "1541"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val romPickerLauncher: ActivityResultLauncher<Intent> =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val assetDir = getExternalFilesDir(null) ?: filesDir
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val uris = mutableListOf<Uri>()
|
||||
result.data?.clipData?.let { clip ->
|
||||
for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri)
|
||||
} ?: result.data?.data?.let { uris.add(it) }
|
||||
|
||||
for (uri in uris) {
|
||||
val name = displayNameForUri(uri) ?: continue
|
||||
val romName = romNameForFile(name) ?: continue
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
File(assetDir, romName).outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
appendLog("Imported ROM: $romName")
|
||||
}
|
||||
Log.d(TAG, "Copied bundled ROM: $name")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to copy bundled ROM $name", e)
|
||||
}
|
||||
val stillMissing = missingRoms(assetDir)
|
||||
if (stillMissing.isEmpty()) {
|
||||
startEmulator(assetDir)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
this, getString(R.string.roms_still_missing, stillMissing.joinToString()),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
showRomImportDialog(assetDir)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showRomImportDialog(dir: File) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.roms_missing_title)
|
||||
.setMessage(getString(R.string.roms_missing_msg, missingRoms(dir).joinToString()))
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.roms_import_button) { _, _ ->
|
||||
launchPicker(romPickerLauncher, getString(R.string.rom_picker_title), multiSelect = true)
|
||||
}
|
||||
.setNeutralButton(R.string.roms_download_button) { _, _ -> downloadRoms(dir) }
|
||||
.show()
|
||||
}
|
||||
|
||||
// The same VICE 3.8 source tarball already bundled at res/vice-3.8.tar.gz
|
||||
// (used to cross-compile VICE itself), fetched from VICE's own official
|
||||
// GitHub release rather than shipped in the APK. The four ROMs are
|
||||
// extracted from it client-side — see docs/publish.md §0 for why this is
|
||||
// not equivalent to bundling them in the app.
|
||||
private val viceTarballUrl =
|
||||
"https://github.com/VICE-Team/svn-mirror/releases/download/3.8.0/vice-3.8.tar.gz"
|
||||
|
||||
private fun downloadRoms(destDir: File) {
|
||||
val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() }
|
||||
val progressBar = android.widget.ProgressBar(
|
||||
this, null, android.R.attr.progressBarStyleHorizontal
|
||||
).apply { isIndeterminate = true }
|
||||
val statusTv = TextView(this).apply {
|
||||
text = getString(R.string.roms_downloading)
|
||||
textSize = 12f
|
||||
setTextColor(Color.parseColor("#CCCCCC"))
|
||||
setPadding(0, dp(8), 0, 0)
|
||||
}
|
||||
val content = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
setPadding(dp(20), dp(16), dp(20), dp(8))
|
||||
setBackgroundColor(Color.parseColor("#1A1A2E"))
|
||||
addView(progressBar)
|
||||
addView(statusTv)
|
||||
}
|
||||
val progressDialog = AlertDialog.Builder(this)
|
||||
.setTitle(R.string.roms_download_button)
|
||||
.setView(content)
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
|
||||
Thread {
|
||||
try {
|
||||
val conn = (URL(viceTarballUrl).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15000
|
||||
readTimeout = 30000
|
||||
instanceFollowRedirects = true
|
||||
}
|
||||
try {
|
||||
conn.connect()
|
||||
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw IOException("HTTP ${conn.responseCode}")
|
||||
}
|
||||
val total = conn.contentLengthLong
|
||||
var lastPercent = -1
|
||||
val countingStream = CountingInputStream(conn.inputStream) { downloaded ->
|
||||
if (total > 0) {
|
||||
val percent = ((downloaded * 100) / total).toInt()
|
||||
if (percent != lastPercent) {
|
||||
lastPercent = percent
|
||||
mainHandler.post {
|
||||
progressBar.isIndeterminate = false
|
||||
progressBar.progress = percent
|
||||
statusTv.text = getString(R.string.roms_downloading_progress, percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
extractRomsFromTarGz(countingStream, destDir)
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
mainHandler.post {
|
||||
progressDialog.dismiss()
|
||||
val stillMissing = missingRoms(destDir)
|
||||
if (stillMissing.isEmpty()) {
|
||||
appendLog("ROM download complete")
|
||||
startEmulator(destDir)
|
||||
} else {
|
||||
appendLog("ROM download incomplete: ${stillMissing.joinToString()}")
|
||||
Toast.makeText(
|
||||
this, getString(R.string.roms_still_missing, stillMissing.joinToString()),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
showRomImportDialog(destDir)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "ROM download failed", e)
|
||||
mainHandler.post {
|
||||
progressDialog.dismiss()
|
||||
appendLog("ROM download failed: ${e.message}")
|
||||
Toast.makeText(this, R.string.roms_download_failed, Toast.LENGTH_LONG).show()
|
||||
showRomImportDialog(destDir)
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
// Minimal USTAR reader — pulls just the four named ROM entries out of the
|
||||
// gzipped VICE source tarball without needing a tar library dependency.
|
||||
private fun extractRomsFromTarGz(rawInput: InputStream, destDir: File) {
|
||||
val wantedEntries = mapOf(
|
||||
"vice-3.8/data/C64/kernal-901227-03.bin" to "kernal",
|
||||
"vice-3.8/data/C64/basic-901226-01.bin" to "basic",
|
||||
"vice-3.8/data/C64/chargen-901225-01.bin" to "chargen",
|
||||
"vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin" to "1541",
|
||||
)
|
||||
GZIPInputStream(rawInput).use { gz ->
|
||||
val header = ByteArray(512)
|
||||
while (true) {
|
||||
var read = 0
|
||||
while (read < 512) {
|
||||
val n = gz.read(header, read, 512 - read)
|
||||
if (n < 0) return
|
||||
read += n
|
||||
}
|
||||
val name = String(header, 0, 100, Charsets.US_ASCII).trimEnd('\u0000', ' ')
|
||||
val sizeField = String(header, 124, 12, Charsets.US_ASCII).trim('\u0000', ' ')
|
||||
val size = if (sizeField.isEmpty()) 0L else sizeField.toLong(8)
|
||||
|
||||
val destName = wantedEntries[name]
|
||||
if (destName != null) {
|
||||
File(destDir, destName).outputStream().use { out -> copyExactly(gz, out, size) }
|
||||
} else {
|
||||
skipExactly(gz, size)
|
||||
}
|
||||
val remainder = size % 512
|
||||
if (remainder != 0L) skipExactly(gz, 512 - remainder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyExactly(input: InputStream, output: OutputStream, size: Long) {
|
||||
val buf = ByteArray(8192)
|
||||
var remaining = size
|
||||
while (remaining > 0) {
|
||||
val n = input.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt())
|
||||
if (n < 0) break
|
||||
output.write(buf, 0, n)
|
||||
remaining -= n
|
||||
}
|
||||
}
|
||||
|
||||
private fun skipExactly(input: InputStream, size: Long) {
|
||||
val buf = ByteArray(8192)
|
||||
var remaining = size
|
||||
while (remaining > 0) {
|
||||
val n = input.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt())
|
||||
if (n < 0) break
|
||||
remaining -= n
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,3 +1094,25 @@ class CompanionServer(
|
||||
|
||||
/** One hero's save data read from a D64 PRG sector. [values] is mutable for in-place editing. */
|
||||
private class HeroPrg(val name: String, val sectorOff: Int, val values: IntArray)
|
||||
|
||||
/** Reports cumulative bytes read via [onProgress] as the wrapped stream is consumed. */
|
||||
private class CountingInputStream(
|
||||
private val wrapped: InputStream,
|
||||
private val onProgress: (Long) -> Unit
|
||||
) : InputStream() {
|
||||
private var count = 0L
|
||||
|
||||
override fun read(): Int {
|
||||
val b = wrapped.read()
|
||||
if (b >= 0) { count++; onProgress(count) }
|
||||
return b
|
||||
}
|
||||
|
||||
override fun read(b: ByteArray, off: Int, len: Int): Int {
|
||||
val n = wrapped.read(b, off, len)
|
||||
if (n > 0) { count += n; onProgress(count) }
|
||||
return n
|
||||
}
|
||||
|
||||
override fun close() = wrapped.close()
|
||||
}
|
||||
|
||||
@@ -17,6 +17,17 @@
|
||||
<!-- Disk picker -->
|
||||
<string name="picker_title">SCHWUM-Disketten auswählen</string>
|
||||
|
||||
<!-- ROM import -->
|
||||
<string name="roms_missing_title">C64-ROMs erforderlich</string>
|
||||
<string name="roms_missing_msg">Diese App enthält keine Commodore-ROM-Dateien (sie sind urheberrechtlich geschützt). Bitte eigenen, legal erworbenen ROM-Dump bereitstellen. Fehlend: %1$s</string>
|
||||
<string name="roms_import_button">ROMs importieren</string>
|
||||
<string name="rom_picker_title">kernal-, basic-, chargen-, 1541-ROM-Dateien auswählen</string>
|
||||
<string name="roms_still_missing">Noch fehlend: %1$s</string>
|
||||
<string name="roms_download_button">ROMs herunterladen</string>
|
||||
<string name="roms_download_failed">ROM-Download fehlgeschlagen — Netzwerkverbindung prüfen</string>
|
||||
<string name="roms_downloading">Wird heruntergeladen…</string>
|
||||
<string name="roms_downloading_progress">Wird heruntergeladen… %1$d%%</string>
|
||||
|
||||
<!-- Load-order warning toast -->
|
||||
<string name="load_a_first">%1$s zuerst laden — %2$s setzt dort fort</string>
|
||||
|
||||
|
||||
@@ -19,6 +19,17 @@
|
||||
<!-- Disk picker -->
|
||||
<string name="picker_title">Select SCHWUM disk images</string>
|
||||
|
||||
<!-- ROM import -->
|
||||
<string name="roms_missing_title">C64 ROMs required</string>
|
||||
<string name="roms_missing_msg">This app does not include Commodore ROM files (they\'re copyrighted). Please supply your own legally-obtained dump. Missing: %1$s</string>
|
||||
<string name="roms_import_button">Import ROMs</string>
|
||||
<string name="rom_picker_title">Select kernal, basic, chargen, 1541 ROM files</string>
|
||||
<string name="roms_still_missing">Still missing: %1$s</string>
|
||||
<string name="roms_download_button">Download ROMs</string>
|
||||
<string name="roms_download_failed">ROM download failed — check your network connection</string>
|
||||
<string name="roms_downloading">Downloading…</string>
|
||||
<string name="roms_downloading_progress">Downloading… %1$d%%</string>
|
||||
|
||||
<!-- Load-order warning toast -->
|
||||
<string name="load_a_first">Load %1$s first — %2$s continues from where it left off</string>
|
||||
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Extracts the C64 and 1541 ROMs needed by vice_jni.c from vice-3.8.tar.gz.
|
||||
#
|
||||
# This is a manual/standalone equivalent of the `extractViceRoms` Gradle task
|
||||
# in app/build.gradle.kts — same source paths, same renaming. Useful for
|
||||
# inspecting the ROMs outside a full Gradle build (e.g. for the "bring your
|
||||
# own ROM" flow described in docs/publish.md §0).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARBALL="$SCRIPT_DIR/vice-3.8.tar.gz"
|
||||
DEST="${1:-$SCRIPT_DIR/roms}"
|
||||
|
||||
if [[ ! -f "$TARBALL" ]]; then
|
||||
echo "error: $TARBALL not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DEST"
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
tar -xzf "$TARBALL" -C "$WORK_DIR" \
|
||||
vice-3.8/data/C64/kernal-901227-03.bin \
|
||||
vice-3.8/data/C64/basic-901226-01.bin \
|
||||
vice-3.8/data/C64/chargen-901225-01.bin \
|
||||
"vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin"
|
||||
|
||||
cp "$WORK_DIR/vice-3.8/data/C64/kernal-901227-03.bin" "$DEST/kernal"
|
||||
cp "$WORK_DIR/vice-3.8/data/C64/basic-901226-01.bin" "$DEST/basic"
|
||||
cp "$WORK_DIR/vice-3.8/data/C64/chargen-901225-01.bin" "$DEST/chargen"
|
||||
cp "$WORK_DIR/vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin" "$DEST/1541"
|
||||
|
||||
echo "Extracted kernal, basic, chargen, 1541 to $DEST"
|
||||
Reference in New Issue
Block a user