diff --git a/android/README.md b/android/README.md index 8964b76..de0ab05 100644 --- a/android/README.md +++ b/android/README.md @@ -35,12 +35,12 @@ cd xsystem35-sdl2/android ## How to use 1. Create a ZIP file containing all the game files (`*.ALD`) and [configuration files](https://haniwa.technology/games/preparing-a-game-directory.html), and transfer it to your device. -2. When you start the app, a file chooser opens. Select the zip file. -3. The game starts. Two-finger touch is treated as a right click. -4. Game data persists in the app's internal storage. Use the device's Settings app if you want to clear it (eg. before installing another game). +2. Open the app. A list of installed games is displayed. Since nothing has been installed yet, only the "Install from ZIP" button is displayed. Tap it. +3. Select the zip you created in 1. +4. The game starts. Two-finger touch is treated as a right click. -Note that this Android port is still incomplete; see below. +To uninstall a game, long-tap the title in the game list. ## TODO -- Better launcher +- Improve launcher - MP3 / MIDI BGM support (currently only ogg is supported) diff --git a/android/app/build.gradle b/android/app/build.gradle index 734aacb..3307165 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -6,6 +6,7 @@ if (buildAsApplication) { else { apply plugin: 'com.android.library' } +apply plugin: 'kotlin-android' android { compileSdkVersion 26 @@ -70,6 +71,7 @@ android { dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" } String getInstalledCmakeVersion() { diff --git a/android/app/src/main/java/io/github/kichikuou/xsystem35/GameActivity.java b/android/app/src/main/java/io/github/kichikuou/xsystem35/GameActivity.java deleted file mode 100644 index f44d216..0000000 --- a/android/app/src/main/java/io/github/kichikuou/xsystem35/GameActivity.java +++ /dev/null @@ -1,35 +0,0 @@ -/* Copyright (C) 2019 - * - * 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.xsystem35; - -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", - "xsystem35" - }; - } -} diff --git a/android/app/src/main/java/io/github/kichikuou/xsystem35/GameActivity.kt b/android/app/src/main/java/io/github/kichikuou/xsystem35/GameActivity.kt new file mode 100644 index 0000000..a32d8e7 --- /dev/null +++ b/android/app/src/main/java/io/github/kichikuou/xsystem35/GameActivity.kt @@ -0,0 +1,47 @@ +/* Copyright (C) 2019 + * + * 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.xsystem35 + +import org.libsdl.app.SDLActivity +import java.io.File + +// Intent for this activity must have two extras: +// - EXTRA_GAME_ROOT (string): A path to the game installation. +// - EXTRA_TITLE_FILE (string): A file to which the game title will be written. +class GameActivity : SDLActivity() { + companion object { + const val EXTRA_GAME_ROOT = "GAME_ROOT" + const val EXTRA_TITLE_FILE = "TITLE_FILE" + } + + override fun getLibraries(): Array { + return arrayOf("hidapi", "SDL2", "xsystem35") + } + + override fun getArguments(): Array { + 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 + intent.getStringExtra(EXTRA_TITLE_FILE)?.let { File(it).writeText(str) } + } +} diff --git a/android/app/src/main/java/io/github/kichikuou/xsystem35/LauncherActivity.java b/android/app/src/main/java/io/github/kichikuou/xsystem35/LauncherActivity.java deleted file mode 100644 index bba6ef8..0000000 --- a/android/app/src/main/java/io/github/kichikuou/xsystem35/LauncherActivity.java +++ /dev/null @@ -1,91 +0,0 @@ -/* Copyright (C) 2019 - * - * 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.xsystem35; - -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); - } -} diff --git a/android/app/src/main/java/io/github/kichikuou/xsystem35/LauncherActivity.kt b/android/app/src/main/java/io/github/kichikuou/xsystem35/LauncherActivity.kt new file mode 100644 index 0000000..5a1cfc2 --- /dev/null +++ b/android/app/src/main/java/io/github/kichikuou/xsystem35/LauncherActivity.kt @@ -0,0 +1,232 @@ +/* Copyright (C) 2019 + * + * 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.xsystem35 + +import android.annotation.SuppressLint +import android.app.* +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Message +import android.util.Log +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.ListView +import java.io.* + +import java.nio.charset.Charset +import java.util.Locale +import java.util.zip.ZipInputStream +import kotlin.collections.ArrayList + +const val PROGRESS = 0 +const val SUCCESS = 1 +const val FAILURE = 2 + +class LauncherActivity : ListActivity(), AdapterView.OnItemLongClickListener { + private lateinit var gm: GameManager + private lateinit var adapter: ArrayAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + gm = GameManager(filesDir) + + val items = gm.titles.toMutableList() + items.add("Install from ZIP") + adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, items) + listAdapter = adapter + listView.onItemLongClickListener = this + } + + override fun onListItemClick(l: ListView?, v: View?, position: Int, id: Long) { + super.onListItemClick(l, v, position, id) + if (position < gm.games.size) { + startGame(gm.games[position].path) + } else { + val i = Intent(Intent.ACTION_GET_CONTENT) + i.type = "application/zip" + startActivityForResult(Intent.createChooser(i, "Choose a file"), 0) + } + } + + override fun onItemLongClick(a: AdapterView<*>?, v: View?, position: Int, id: Long): Boolean { + if (position < gm.games.size) { + AlertDialog.Builder(this).setTitle("Confirm") + .setMessage("Uninstall ${gm.games[position].title}?") + .setPositiveButton("OK") {_, _ -> uninstall(position)} + .setNegativeButton("Cancel") {_, _ -> } + .show() + } + return true + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (resultCode != RESULT_OK) + return + val uri = data?.data ?: return + val input = contentResolver.openInputStream(uri) ?: return + val dialog = ProgressDialogFragment() + dialog.show(fragmentManager, "progress_dialog") + + @SuppressLint("HandlerLeak") // Should be okay as installer thread has a short lifetime + val handler = object : Handler() { + override fun handleMessage(msg: Message) { + when (msg.what) { + PROGRESS -> { + dialog.setProgress(msg.obj as String) + } + SUCCESS -> { + dialog.dismiss() + startGame(msg.obj as File) + } + FAILURE -> { + dialog.dismiss() + errorDialog(msg.obj as String) + } + } + } + } + gm.install(input, handler) + } + + private fun startGame(path: File) { + val gameRoot = findGameRoot(path) + if (gameRoot == null) { + errorDialog("Cannot find game files (*.ald)") + return + } + val i = Intent() + i.setClass(applicationContext, GameActivity::class.java) + i.putExtra(GameActivity.EXTRA_GAME_ROOT, gameRoot.path) + i.putExtra(GameActivity.EXTRA_TITLE_FILE, File(path, GameManager.TITLE_FILE).path) + startActivity(i) + } + + private fun uninstall(id: Int) { + gm.uninstall(id) + adapter.remove(adapter.getItem(id)) + } + + private fun errorDialog(msg: String) { + AlertDialog.Builder(this).setTitle("Error") + .setMessage(msg) + .setPositiveButton("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("Installing") + setCancelable(true) + } + } + fun setProgress(msg: String) { + dialog.setMessage(msg) + } +} + +private class GameManager(private val rootDir: File) { + companion object { + const val TITLE_FILE = "title.txt" + } + + data class Entry(val path: File, val title: String) + val games: ArrayList = arrayListOf() + val titles: List + get() = games.map(Entry::title) + + init { + for (path in rootDir.listFiles()) { + if (!path.isDirectory) + continue + try { + val title = File(path, TITLE_FILE).readText() + games.add(Entry(path, title)) + } catch (e: IOException) { + // Incomplete game installation. Delete it. + path.deleteRecursively() + } + } + } + + fun install(input: InputStream, handler: Handler) { + var i = 0 + while (true) { + val f = File(rootDir, i++.toString()) + if (!f.exists() && f.mkdir()) { + val t = Thread { + extractFiles(input, f, handler) + } + t.start() + return + } + } + } + + fun uninstall(id: Int) { + games[id].path.deleteRecursively() + games.removeAt(id) + } + + private fun extractFiles(input: InputStream, outDir: File, handler: Handler) { + try { + val zip = if (Build.VERSION.SDK_INT >= 24) { + ZipInputStream(input.buffered(), Charset.forName("Shift_JIS")) + } else { + ZipInputStream(input.buffered()) + } + while (true) { + val zipEntry = zip.nextEntry ?: break + Log.d("extractFiles", zipEntry.name) + val path = File(outDir, zipEntry.name) + if (zipEntry.isDirectory) + continue + path.parentFile.mkdirs() + handler.sendMessage(handler.obtainMessage(PROGRESS, "Extracting ${zipEntry.name}...")) + val output = FileOutputStream(path).buffered() + zip.copyTo(output) + output.close() + } + zip.close() + handler.sendMessage(handler.obtainMessage(SUCCESS, outDir)) + } catch (e: UTFDataFormatException) { + // Attempted to read Shift_JIS zip in Android < 7 + handler.sendMessage(handler.obtainMessage(FAILURE, "This ZIP file is unsupported.")) + } catch (e: IOException) { + Log.e("launcher", "Failed to extract ZIP", e) + handler.sendMessage(handler.obtainMessage(FAILURE, "Failed to extract ZIP.")) + } + } +} + +private fun findGameRoot(path: File): File? { + path.walkTopDown().forEach { + val name = it.name.toLowerCase(Locale.US) + if (name == "xsystem35.gr" || name.endsWith(".ald")) + return it.parentFile + } + return null +} diff --git a/android/build.gradle b/android/build.gradle index f6f90b2..6e1d82a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,12 +1,14 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { + ext.kotlin_version = '1.3.61' repositories { jcenter() google() } dependencies { classpath 'com.android.tools.build:gradle:3.2.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/src/xsystem35.c b/src/xsystem35.c index 5e6a2ad..903c6f7 100644 --- a/src/xsystem35.c +++ b/src/xsystem35.c @@ -801,9 +801,9 @@ int main(int argc, char **argv) { saved_argv = argv; #ifdef __ANDROID__ - // Chdir to the internal storage path where the launcher should have placed - // game files. - chdir(SDL_AndroidGetInternalStoragePath()); + // Handle -gamedir option here so that .xsys35rc is loaded from that directory. + if (strcmp(argv[1], "-gamedir") == 0) + chdir(argv[2]); #endif load_profile();