Android: Copy the new launcher code from xsystem35-sdl2 70c6480

This commit is contained in:
kichikuou
2020-02-09 21:47:04 +09:00
parent 274027f703
commit ccb5bda7f5
11 changed files with 735 additions and 139 deletions
@@ -1,35 +0,0 @@
/* Copyright (C) 2019 <KichikuouChrome@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*
*/
package io.github.kichikuou.system3;
import org.libsdl.app.SDLActivity;
public class GameActivity extends SDLActivity {
@Override
protected String[] getLibraries() {
return new String[] {
"hidapi",
"SDL2",
// "SDL2_image",
// "SDL2_mixer",
// "SDL2_net",
// "SDL2_ttf",
"system3"
};
}
}
@@ -0,0 +1,192 @@
/* Copyright (C) 2019 <KichikuouChrome@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*
*/
package io.github.kichikuou.system3
import android.media.MediaPlayer
import android.os.Bundle
import android.util.Base64
import android.util.Log
import org.libsdl.app.SDLActivity
import java.io.File
import java.io.IOException
// Intent for this activity must have the following extra:
// - EXTRA_GAME_ROOT (string): A path to the game installation.
class GameActivity : SDLActivity() {
companion object {
const val EXTRA_GAME_ROOT = "GAME_ROOT"
}
private lateinit var gameRoot: File
private lateinit var cdda: CddaPlayer
private val midi = MidiPlayer()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
gameRoot = File(intent.getStringExtra(EXTRA_GAME_ROOT))
cdda = CddaPlayer(File(gameRoot, Launcher.PLAYLIST_FILE))
}
override fun onStop() {
super.onStop()
cdda.onActivityStop()
midi.onActivityStop()
}
override fun onResume() {
super.onResume()
cdda.onActivityResume()
midi.onActivityResume()
}
override fun getLibraries(): Array<String> {
return arrayOf("hidapi", "SDL2", "system3")
}
override fun getArguments(): Array<String> {
return arrayOf("-gamedir", intent.getStringExtra(EXTRA_GAME_ROOT))
}
override fun setTitle(title: CharSequence?) {
super.setTitle(title)
val str = title?.toString()?.substringAfter(':', "")
if (str.isNullOrEmpty())
return
File(gameRoot, Launcher.TITLE_FILE).writeText(str)
Launcher.updateGameList()
}
// These functions are called in the SDL thread by JNI.
@Suppress("unused") fun cddaStart(track: Int, loop: Int) = cdda.start(track, loop)
@Suppress("unused") fun cddaStop() = cdda.stop()
@Suppress("unused") fun cddaCurrentPosition() = cdda.currentPosition()
@Suppress("unused") fun midiStart(buf: ByteArray, loop: Int) = midi.start(buf, loop)
@Suppress("unused") fun midiStop() = midi.stop()
@Suppress("unused") fun midiCurrentPosition() = midi.currentPosition()
}
private class CddaPlayer(private val playlistPath: File) {
private val playlist =
try {
playlistPath.readLines()
} catch (e: IOException) {
Log.e("loadPlaylist", "Cannot load $playlistPath", e)
emptyList<String>()
}
private var currentTrack = 0
private val player = MediaPlayer()
private var playerPaused = false
fun start(track: Int, loop: Int) {
val f = playlist.elementAtOrNull(track)
if (f.isNullOrEmpty()) {
Log.w("cddaStart", "No playlist entry for track $track")
return
}
Log.v("cddaStart", f)
try {
player.apply {
reset()
setDataSource(File(playlistPath.parent, f).path)
isLooping = loop == 0
prepare()
start()
}
currentTrack = track
} catch (e: IOException) {
Log.e("cddaStart", "Cannot play $f", e)
player.reset()
}
}
fun stop() {
if (currentTrack > 0 && player.isPlaying) {
player.stop()
currentTrack = 0
}
}
fun currentPosition(): Int {
if (currentTrack == 0)
return 0
val frames = player.currentPosition * 75 / 1000
return currentTrack or (frames shl 8)
}
fun onActivityStop() {
if (currentTrack > 0 && player.isPlaying) {
player.pause()
playerPaused = true
}
}
fun onActivityResume() {
if (playerPaused) {
player.start()
playerPaused = false
}
}
}
private class MidiPlayer {
private val player = MediaPlayer()
private var playing = false
private var playerPaused = false
fun start(buf: ByteArray, loop: Int) {
val url = "data:audio/midi;base64," + Base64.encodeToString(buf, Base64.DEFAULT)
try {
player.apply {
reset()
setDataSource(url)
isLooping = loop == 0
prepare()
start()
}
playing = true
} catch (e: IOException) {
Log.e("midiStart", "Cannot play midi", e)
player.reset()
}
}
fun stop() {
if (playing && player.isPlaying) {
player.stop()
playing = false
}
}
fun currentPosition(): Int {
return if (playing) player.currentPosition else 0
}
fun onActivityStop() {
if (playing && player.isPlaying) {
player.pause()
playerPaused = true
}
}
fun onActivityResume() {
if (playerPaused) {
player.start()
playerPaused = false
}
}
}
@@ -0,0 +1,280 @@
/* Copyright (C) 2020 <KichikuouChrome@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*
*/
package io.github.kichikuou.system3
import android.annotation.SuppressLint
import android.os.Build
import android.os.Handler
import android.os.Message
import android.util.Log
import java.io.*
import java.lang.StringBuilder
import java.nio.charset.Charset
import java.util.*
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
private var gLauncher: Launcher? = null
interface LauncherObserver {
fun onGameListChange()
fun onInstallProgress(path: String)
fun onInstallSuccess(path: File)
fun onInstallFailure(msgId: Int)
}
private const val SAVE_DIR = "save"
private const val PROGRESS = 0
private const val SUCCESS = 1
private const val FAILURE = 2
class Launcher private constructor(private val rootDir: File) {
companion object {
const val TITLE_FILE = "title.txt"
const val PLAYLIST_FILE = "playlist.txt"
fun getInstance(rootDir: File): Launcher {
if (gLauncher == null) {
gLauncher = Launcher(rootDir)
}
return gLauncher!!
}
fun updateGameList() {
gLauncher?.updateGameList()
}
}
data class Entry(val path: File, val title: String)
val games = arrayListOf<Entry>()
val titles: List<String>
get() = games.map(Entry::title)
var observer: LauncherObserver? = null
var isInstalling = false
private set
init {
updateGameList()
}
fun install(input: InputStream) {
val dir = createDirForGame()
@SuppressLint("HandlerLeak")
val handler = object : Handler() {
override fun handleMessage(msg: Message) {
when (msg.what) {
PROGRESS -> {
observer?.onInstallProgress(msg.obj as String)
}
SUCCESS -> {
isInstalling = false
observer?.onInstallSuccess(msg.obj as File)
}
FAILURE -> {
isInstalling = false
observer?.onInstallFailure(msg.obj as Int)
}
}
}
}
val t = Thread {
extractFiles(input, dir, handler)
}
t.start()
isInstalling = true
}
fun uninstall(id: Int) {
games[id].path.deleteRecursively()
games.removeAt(id)
observer?.onGameListChange()
}
private fun updateGameList() {
var saveDirFound = false
for (path in rootDir.listFiles()) {
if (!path.isDirectory)
continue
if (path.name == SAVE_DIR) {
saveDirFound = true
continue
}
try {
val title = File(path, TITLE_FILE).readText()
games.add(Entry(path, title))
} catch (e: IOException) {
// Incomplete game installation. Delete it.
path.deleteRecursively()
}
}
if (!saveDirFound) {
File(rootDir, SAVE_DIR).mkdir()
}
}
private fun createDirForGame(): File {
var i = 0
while (true) {
val f = File(rootDir, i++.toString())
if (!f.exists() && f.mkdir()) {
return f
}
}
}
// Throws IOException
fun exportSaveData(output: OutputStream) {
if (Build.VERSION.SDK_INT >= 24) {
ZipOutputStream(output.buffered(), Charset.forName("Shift_JIS"))
} else {
ZipOutputStream(output.buffered())
}.use { zip ->
for (path in File(rootDir, SAVE_DIR).listFiles()) {
if (path.isDirectory || path.name.endsWith(".asd."))
continue
val pathInZip = "${SAVE_DIR}/${path.name}"
Log.i("exportSaveData", pathInZip)
zip.putNextEntry(ZipEntry(pathInZip))
path.inputStream().buffered().use {
it.copyTo(zip)
}
}
}
}
fun importSaveData(input: InputStream): Int? {
try {
var imported = false
forEachZipEntry(input) { zipEntry, zip ->
// Process only files directly under save/
if (zipEntry.isDirectory || !zipEntry.name.startsWith("save/") ||
zipEntry.name.count{it == '/'} != 1)
return@forEachZipEntry
Log.i("importSaveData", zipEntry.name)
FileOutputStream(File(rootDir, zipEntry.name)).buffered().use {
zip.copyTo(it)
}
imported = true
}
return if (imported) null else R.string.no_data_to_import
} catch (e: UTFDataFormatException) {
// Attempted to read Shift_JIS zip in Android < 7
return R.string.unsupported_zip
} catch (e: IOException) {
Log.e("launcher", "Failed to extract ZIP", e)
return R.string.zip_extraction_error
}
}
private fun extractFiles(input: InputStream, outDir: File, handler: Handler) {
try {
val configWriter = GameConfigWriter()
forEachZipEntry(input) { zipEntry, zip ->
Log.i("extractFiles", zipEntry.name)
val path = File(outDir, zipEntry.name)
if (zipEntry.isDirectory)
return@forEachZipEntry
path.parentFile.mkdirs()
handler.sendMessage(handler.obtainMessage(PROGRESS, zipEntry.name))
FileOutputStream(path).buffered().use {
zip.copyTo(it)
}
configWriter.maybeAdd(zipEntry.name)
}
configWriter.write(outDir)
handler.sendMessage(handler.obtainMessage(SUCCESS, outDir))
} catch (e: InstallFailureException) {
handler.sendMessage(handler.obtainMessage(FAILURE, e.msgId))
} catch (e: UTFDataFormatException) {
// Attempted to read Shift_JIS zip in Android < 7
handler.sendMessage(handler.obtainMessage(FAILURE, R.string.unsupported_zip))
} catch (e: IOException) {
Log.e("launcher", "Failed to extract ZIP", e)
handler.sendMessage(handler.obtainMessage(FAILURE, R.string.zip_extraction_error))
}
}
class InstallFailureException(val msgId: Int) : Exception()
// A helper class which generates xsystem35.gr and playlist.txt in the game root directory.
private class GameConfigWriter {
private val grb = StringBuilder()
private var basename: String? = null
private val aldRegex = """(.*?)([a-z])([a-z])\.ald""".toRegex(RegexOption.IGNORE_CASE)
private val resourceType = mapOf(
"d" to "Data",
"g" to "Graphics",
"m" to "Midi",
"r" to "Resource",
"s" to "Scenario",
"w" to "Wave")
private val audioRegex = """.*?(\d+)\.(wav|mp3|ogg)""".toRegex(RegexOption.IGNORE_CASE)
private val audioFiles: Array<String?> = arrayOfNulls(100)
fun maybeAdd(path: String) {
val name = File(path).name
if (name.toLowerCase(Locale.US) == "system39.ain") {
grb.appendln("Ain $path")
return
}
aldRegex.matchEntire(name)?.let {
val type = resourceType[it.groupValues[2].toLowerCase(Locale.US)]
val id = it.groupValues[3].toUpperCase(Locale.US)
if (type != null) {
grb.appendln("$type$id $path")
basename = it.groupValues[1]
}
}
audioRegex.matchEntire(name)?.let {
val track = it.groupValues[1].toInt()
if (track < audioFiles.size)
audioFiles[track] = path
}
}
fun write(outDir: File) {
basename ?: throw InstallFailureException(R.string.cannot_find_adisk)
for (id in 'A' .. 'Z') {
grb.appendln("Save$id ../save/${basename}s${id.toLowerCase()}.asd")
}
val gr = grb.toString()
Log.i("xsystem35.gr", gr)
File(outDir, "xsystem35.gr").writeText(gr)
val playlist = audioFiles.joinToString("\n") { it ?: "" }.trimEnd('\n')
File(outDir, PLAYLIST_FILE).writeText(playlist)
}
}
}
private fun forEachZipEntry(input: InputStream, action: (ZipEntry, ZipInputStream) -> Unit) {
val zip = if (Build.VERSION.SDK_INT >= 24) {
ZipInputStream(input.buffered(), Charset.forName("Shift_JIS"))
} else {
ZipInputStream(input.buffered())
}
zip.use {
while (true) {
val zipEntry = zip.nextEntry ?: break
action(zipEntry, zip)
}
}
}
@@ -1,91 +0,0 @@
/* Copyright (C) 2019 <KichikuouChrome@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*
*/
package io.github.kichikuou.system3;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
public class LauncherActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent i = new Intent(Intent.ACTION_GET_CONTENT);
i.setType("application/zip");
startActivityForResult(Intent.createChooser(i, "Choose a file"), 0);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (resultCode == RESULT_OK)
extractFiles(data.getData(), getApplicationContext().getFilesDir());
startGame();
}
boolean extractFiles(Uri zipUri, File outDir) {
try {
InputStream input = getContentResolver().openInputStream(zipUri);
if (input == null)
return false;
ZipInputStream zip;
if (Build.VERSION.SDK_INT >= 24)
zip = new ZipInputStream(new BufferedInputStream(input), Charset.forName("Shift_JIS"));
else
zip = new ZipInputStream(new BufferedInputStream(input));
byte[] buffer = new byte[4096];
ZipEntry zipEntry;
while ((zipEntry = zip.getNextEntry()) != null) {
File entryName = new File(zipEntry.getName());
if (zipEntry.isDirectory())
continue;
Log.d("Extracting", entryName.toString());
File outFile = new File(outDir, entryName.getName());
BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(outFile));
int size;
while ((size = zip.read(buffer)) > 0) {
output.write(buffer, 0, size);
}
output.close();
}
zip.close();
return true;
} catch (IOException e) {
Log.e("launcher", "Error while extracting zip", e);
return false;
}
}
void startGame() {
Intent i = new Intent();
i.setClass(getApplicationContext(), GameActivity.class);
startActivity(i);
}
}
@@ -0,0 +1,199 @@
/* Copyright (C) 2019 <KichikuouChrome@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*
*/
package io.github.kichikuou.system3
import android.app.*
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.Toast
import java.io.*
private const val CONTENT_TYPE_ZIP = "application/zip"
private const val INSTALL_REQUEST = 1
private const val SAVEDATA_EXPORT_REQUEST = 2
private const val SAVEDATA_IMPORT_REQUEST = 3
class LauncherActivity : ListActivity(), AdapterView.OnItemLongClickListener, LauncherObserver {
private lateinit var launcher: Launcher
private var progressDialog: ProgressDialogFragment? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
launcher = Launcher.getInstance(filesDir)
launcher.observer = this
if (launcher.isInstalling) {
showProgressDialog()
}
onGameListChange()
listView.onItemLongClickListener = this
}
override fun onDestroy() {
launcher.observer = null
super.onDestroy()
}
override fun onListItemClick(l: ListView?, v: View?, position: Int, id: Long) {
super.onListItemClick(l, v, position, id)
if (position < launcher.games.size) {
startGame(launcher.games[position].path)
} else {
val i = Intent(Intent.ACTION_GET_CONTENT)
i.type = CONTENT_TYPE_ZIP
startActivityForResult(Intent.createChooser(i, getString(R.string.choose_a_file)), INSTALL_REQUEST)
}
}
override fun onItemLongClick(a: AdapterView<*>?, v: View?, position: Int, id: Long): Boolean {
if (position < launcher.games.size) {
AlertDialog.Builder(this).setTitle(R.string.uninstall_dialog_title)
.setMessage(getString(R.string.uninstall_dialog_message, launcher.games[position].title))
.setPositiveButton(R.string.ok) {_, _ -> uninstall(position)}
.setNegativeButton(R.string.cancel) {_, _ -> }
.show()
}
return true
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.launcher_menu, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.export_savedata -> {
val i = Intent(Intent.ACTION_CREATE_DOCUMENT)
i.type = CONTENT_TYPE_ZIP
i.putExtra(Intent.EXTRA_TITLE, "savedata.zip")
startActivityForResult(i, SAVEDATA_EXPORT_REQUEST)
true
}
R.id.import_savedata -> {
val i = Intent(Intent.ACTION_GET_CONTENT)
i.type = CONTENT_TYPE_ZIP
startActivityForResult(Intent.createChooser(i, getString(R.string.choose_a_file)),
SAVEDATA_IMPORT_REQUEST)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (resultCode != RESULT_OK)
return
val uri = data?.data ?: return
when (requestCode) {
INSTALL_REQUEST -> {
val input = contentResolver.openInputStream(uri) ?: return
showProgressDialog()
launcher.install(input)
}
SAVEDATA_EXPORT_REQUEST -> try {
launcher.exportSaveData(contentResolver.openOutputStream(uri)!!)
Toast.makeText(this, R.string.save_data_export_success, Toast.LENGTH_SHORT).show()
} catch (e: IOException) {
Log.e("launcher", "Failed to export savedata", e)
errorDialog(R.string.save_data_export_error)
}
SAVEDATA_IMPORT_REQUEST -> {
val input = contentResolver.openInputStream(uri) ?: return
val errMsgId = launcher.importSaveData(input)
if (errMsgId == null) {
Toast.makeText(this, R.string.save_data_import_success, Toast.LENGTH_SHORT).show()
} else {
errorDialog(errMsgId)
}
}
}
}
override fun onGameListChange() {
val items = launcher.titles.toMutableList()
items.add(getString(R.string.install_from_zip))
listAdapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, items)
}
override fun onInstallProgress(path: String) {
progressDialog?.setProgress(getString(R.string.install_progress, path))
}
override fun onInstallSuccess(path: File) {
dismissProgressDialog()
startGame(path)
}
override fun onInstallFailure(msgId: Int) {
dismissProgressDialog()
errorDialog(msgId)
}
private fun startGame(path: File) {
val i = Intent()
i.setClass(applicationContext, GameActivity::class.java)
i.putExtra(GameActivity.EXTRA_GAME_ROOT, path.path)
startActivity(i)
}
private fun uninstall(id: Int) {
launcher.uninstall(id)
}
private fun showProgressDialog() {
progressDialog = ProgressDialogFragment()
progressDialog!!.show(fragmentManager, "progress_dialog")
}
private fun dismissProgressDialog() {
progressDialog?.dismiss()
progressDialog = null
}
private fun errorDialog(msgId: Int) {
AlertDialog.Builder(this).setTitle(R.string.error_dialog_title)
.setMessage(msgId)
.setPositiveButton(R.string.ok) {_, _ -> }
.show()
}
}
@Suppress("DEPRECATION") // for ProgressDialog
class ProgressDialogFragment : DialogFragment() {
private lateinit var dialog: ProgressDialog
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
dialog = ProgressDialog(activity)
return dialog.apply {
setTitle(R.string.install_dialog_title)
setCancelable(true)
}
}
fun setProgress(msg: String) {
dialog.setMessage(msg)
}
}