Add in-app file import and episode picker

Assets (ROMs and disk images) now live in getExternalFilesDir, making
  them accessible via USB file transfer and any file manager app without
  adb or root.

  - "Import ROMs" button walks through kernal/basic/chargen picks in
    sequence and restarts the emulator when done
  - "Import Disks" button opens a multi-select picker; files are saved
    as SCHWUM<N><P>.D64 (uppercased for consistent lookup)
  - 2×4 episode grid (1A–4B) lights up each button as its disk image
    becomes available; tapping loads that disk into drive 8
This commit is contained in:
ml
2026-06-05 09:44:06 +02:00
parent 8c7f2e3306
commit 678108530c
4 changed files with 253 additions and 21 deletions
@@ -9,8 +9,10 @@ import java.nio.ByteBuffer
* VICE source must be placed at app/src/main/jni/vice-src/ before building
* (download from https://vice-emu.sourceforge.io/).
*
* ROM files (kernal, basic, chargen) must be copied to the app's filesDir
* before calling initEmulator().
* ROM files (kernal, basic, chargen) and .d64 disk images must be copied to
* the app's external files directory before calling initEmulator().
* That directory is accessible via USB file transfer (no adb needed) at:
* Android/data/de.ladkau.schwertundmagieonpebblecompanionapp/files/
*/
class C64Engine {
@@ -1,13 +1,22 @@
package de.ladkau.schwertundmagieonpebblecompanionapp
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.OpenableColumns
import android.util.Log
import android.widget.Button
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import fi.iki.elonen.NanoHTTPD
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -15,6 +24,10 @@ import java.util.Locale
private const val PORT = 8888
private const val TAG = "SuM"
private data class DiskSlot(val episode: Int, val part: Char, val btnId: Int) {
val fileName: String get() = "SCHWUM${episode}${part}.D64"
}
class MainActivity : AppCompatActivity() {
private lateinit var tvStatus: TextView
@@ -32,6 +45,66 @@ class MainActivity : AppCompatActivity() {
private var emuThread: Thread? = null
@Volatile private var emuRunning = false
// ---- disk slots ---------------------------------------------------------
private val diskSlots = listOf(
DiskSlot(1, 'A', R.id.btn_ep_1a), DiskSlot(1, 'B', R.id.btn_ep_1b),
DiskSlot(2, 'A', R.id.btn_ep_2a), DiskSlot(2, 'B', R.id.btn_ep_2b),
DiskSlot(3, 'A', R.id.btn_ep_3a), DiskSlot(3, 'B', R.id.btn_ep_3b),
DiskSlot(4, 'A', R.id.btn_ep_4a), DiskSlot(4, 'B', R.id.btn_ep_4b),
)
private val episodeButtons = mutableMapOf<DiskSlot, Button>()
// ---- ROM import (sequential: kernal → basic → chargen) -----------------
private val romNames = listOf("kernal", "basic", "chargen")
private var romImportIndex = 0
private val romPickerLauncher: ActivityResultLauncher<Intent> =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
val target = romNames[romImportIndex]
importFileToAssets(uri, target)
appendLog("Imported: $target")
romImportIndex++
if (romImportIndex < romNames.size) {
launchPicker(romPickerLauncher, "Select '${romNames[romImportIndex]}' ROM file")
} else {
romImportIndex = 0
Toast.makeText(this, "ROMs imported — restarting emulator", Toast.LENGTH_SHORT).show()
recreate()
}
} ?: run { romImportIndex = 0 }
} else {
romImportIndex = 0
}
}
// ---- Disk import (multi-select) -----------------------------------------
private val diskPickerLauncher: ActivityResultLauncher<Intent> =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
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) }
var count = 0
for (uri in uris) {
val name = displayNameForUri(uri) ?: continue
importFileToAssets(uri, name.uppercase())
appendLog("Imported: $name")
count++
}
if (count > 0) refreshDiskButtons()
appendLog("Imported $count disk image(s)")
}
}
// ---- lifecycle ----------------------------------------------------------
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
@@ -43,17 +116,35 @@ class MainActivity : AppCompatActivity() {
display = findViewById(R.id.c64_display)
keyboard = findViewById(R.id.c64_keyboard)
findViewById<Button>(R.id.btn_import_roms).setOnClickListener {
romImportIndex = 0
launchPicker(romPickerLauncher, "Select 'kernal' ROM file")
}
findViewById<Button>(R.id.btn_import_disks).setOnClickListener {
launchPicker(diskPickerLauncher, "Select SCHWUM disk images", multiSelect = true)
}
for (slot in diskSlots) {
val btn = findViewById<Button>(slot.btnId)
episodeButtons[slot] = btn
btn.setOnClickListener { loadDiskSlot(slot) }
}
initEmulator()
refreshDiskButtons()
startHttpServer()
wireKeyboard()
}
// ---- emulator -------------------------------------------------------
// ---- emulator -----------------------------------------------------------
private fun initEmulator() {
val romDir = filesDir.absolutePath
val assetDir = getExternalFilesDir(null) ?: filesDir
assetDir.mkdirs()
val romDir = assetDir.absolutePath
val ok = engine.initEmulator(romDir)
tvStatus.text = if (ok) "Emulator ready" else "Emulator: no ROMs (stub mode)"
tvStatus.text = if (ok) "Emulator ready"
else "Copy ROMs + .d64 to:\n$romDir"
Log.d(TAG, "initEmulator=$ok romDir=$romDir")
emuRunning = true
@@ -63,13 +154,12 @@ class MainActivity : AppCompatActivity() {
engine.runFrame()
display.updateFrame(engine)
val elapsed = System.currentTimeMillis() - t0
// Target ~50 fps (20 ms/frame)
if (elapsed < 20) Thread.sleep(20 - elapsed)
}
}.also { it.name = "emu-loop"; it.isDaemon = true; it.start() }
}
// ---- virtual keyboard -----------------------------------------------
// ---- virtual keyboard ---------------------------------------------------
private fun wireKeyboard() {
keyboard.onKeyEvent = { key, pressed ->
@@ -81,7 +171,7 @@ class MainActivity : AppCompatActivity() {
}
}
// ---- HTTP server (watch bridge) -------------------------------------
// ---- HTTP server (watch bridge) -----------------------------------------
private fun startHttpServer() {
server = CompanionServer(PORT, timeFormat,
@@ -101,7 +191,58 @@ class MainActivity : AppCompatActivity() {
}
}
// ---- helpers --------------------------------------------------------
// ---- disk helpers -------------------------------------------------------
private fun diskFile(slot: DiskSlot): File? {
val assetDir = getExternalFilesDir(null) ?: filesDir
return assetDir.listFiles()?.firstOrNull { it.name.uppercase() == slot.fileName }
}
private fun refreshDiskButtons() {
for (slot in diskSlots) {
episodeButtons[slot]?.isEnabled = diskFile(slot) != null
}
}
private fun loadDiskSlot(slot: DiskSlot) {
val file = diskFile(slot) ?: run {
appendLog("Disk not found: ${slot.fileName}")
return
}
val ok = engine.loadDisk(file.absolutePath)
appendLog(if (ok) "Loaded: ${slot.fileName}" else "Load failed: ${slot.fileName}")
}
// ---- file import helpers ------------------------------------------------
private fun launchPicker(
launcher: ActivityResultLauncher<Intent>,
title: String,
multiSelect: Boolean = false
) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
if (multiSelect) putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
launcher.launch(Intent.createChooser(intent, title))
}
private fun importFileToAssets(uri: Uri, fileName: String): File {
val assetDir = getExternalFilesDir(null) ?: filesDir
val dest = File(assetDir, fileName)
contentResolver.openInputStream(uri)?.use { input ->
dest.outputStream().use { output -> input.copyTo(output) }
Log.d(TAG, "Imported $uri${dest.absolutePath}")
} ?: appendLog("Could not open: $uri")
return dest
}
private fun displayNameForUri(uri: Uri): String? =
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
// ---- helpers ------------------------------------------------------------
private fun appendLog(entry: String) {
val ts = timeFormat.format(Date())
@@ -30,12 +30,98 @@
android:textSize="12sp" />
</LinearLayout>
<!-- Import buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="4dp">
<Button
android:id="@+id/btn_import_roms"
android:layout_width="0dp"
android:layout_height="36dp"
android:layout_weight="1"
android:layout_marginEnd="2dp"
android:text="Import ROMs"
android:textSize="11sp"
style="?attr/materialButtonOutlinedStyle" />
<Button
android:id="@+id/btn_import_disks"
android:layout_width="0dp"
android:layout_height="36dp"
android:layout_weight="1"
android:layout_marginStart="2dp"
android:text="Import Disks"
android:textSize="11sp"
style="?attr/materialButtonOutlinedStyle" />
</LinearLayout>
<!-- Episode selector — row A (parts 1A4A), row B (parts 1B4B) -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Load episode:"
android:textColor="#888888"
android:textSize="11sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button android:id="@+id/btn_ep_1a" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginEnd="1dp" android:text="1A" android:textSize="11sp"
android:enabled="false" />
<Button android:id="@+id/btn_ep_2a" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:text="2A" android:textSize="11sp"
android:enabled="false" />
<Button android:id="@+id/btn_ep_3a" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:text="3A" android:textSize="11sp"
android:enabled="false" />
<Button android:id="@+id/btn_ep_4a" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginStart="1dp" android:text="4A" android:textSize="11sp"
android:enabled="false" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="2dp">
<Button android:id="@+id/btn_ep_1b" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginEnd="1dp" android:text="1B" android:textSize="11sp"
android:enabled="false" />
<Button android:id="@+id/btn_ep_2b" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:text="2B" android:textSize="11sp"
android:enabled="false" />
<Button android:id="@+id/btn_ep_3b" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:text="3B" android:textSize="11sp"
android:enabled="false" />
<Button android:id="@+id/btn_ep_4b" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginStart="1dp" android:text="4B" android:textSize="11sp"
android:enabled="false" />
</LinearLayout>
<!-- C64 display (320×200, scales to fill width, capped at 40 % of screen height) -->
<de.ladkau.schwertundmagieonpebblecompanionapp.C64DisplayView
android:id="@+id/c64_display"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="3"
android:layout_marginTop="4dp"
android:background="#000044" />
<!-- Virtual keyboard -->