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:
ml
2026-06-25 09:27:19 +02:00
parent 89c99b50e2
commit aaa6e772a7
26 changed files with 893 additions and 172 deletions
@@ -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)
@@ -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>