From ccb5bda7f5de27c14b6b23e7162edbf2cffe336a Mon Sep 17 00:00:00 2001 From: kichikuou Date: Sun, 9 Feb 2020 16:32:20 +0900 Subject: [PATCH] Android: Copy the new launcher code from xsystem35-sdl2 70c6480 --- android/app/build.gradle | 14 +- android/app/src/main/AndroidManifest.xml | 13 +- .../kichikuou/system3/GameActivity.java | 35 --- .../github/kichikuou/system3/GameActivity.kt | 192 ++++++++++++ .../io/github/kichikuou/system3/Launcher.kt | 280 ++++++++++++++++++ .../kichikuou/system3/LauncherActivity.java | 91 ------ .../kichikuou/system3/LauncherActivity.kt | 199 +++++++++++++ .../app/src/main/res/menu/launcher_menu.xml | 9 + .../app/src/main/res/values-ja/strings.xml | 21 ++ android/app/src/main/res/values/strings.xml | 18 ++ android/build.gradle | 2 + 11 files changed, 735 insertions(+), 139 deletions(-) delete mode 100644 android/app/src/main/java/io/github/kichikuou/system3/GameActivity.java create mode 100644 android/app/src/main/java/io/github/kichikuou/system3/GameActivity.kt create mode 100644 android/app/src/main/java/io/github/kichikuou/system3/Launcher.kt delete mode 100644 android/app/src/main/java/io/github/kichikuou/system3/LauncherActivity.java create mode 100644 android/app/src/main/java/io/github/kichikuou/system3/LauncherActivity.kt create mode 100644 android/app/src/main/res/menu/launcher_menu.xml create mode 100644 android/app/src/main/res/values-ja/strings.xml diff --git a/android/app/build.gradle b/android/app/build.gradle index 42e4daa..123be14 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 @@ -13,10 +14,10 @@ android { if (buildAsApplication) { applicationId "io.github.kichikuou.system3" } - minSdkVersion 16 + minSdkVersion 19 targetSdkVersion 26 versionCode 1 - versionName "1.0" + versionName "0.1.0" externalNativeBuild { cmake { arguments "-DANDROID_APP_PLATFORM=android-16", "-DANDROID_STL=c++_static" @@ -39,7 +40,7 @@ android { aaptOptions { // Disable asset compression for fonts. // Without this, text rendering will be very slow. - noCompress 'ttf' + noCompress 'ttf', 'otf' } externalNativeBuild { cmake { @@ -60,8 +61,8 @@ android { variant.outputs.each { output -> def outputFile = output.outputFile if (outputFile != null && outputFile.name.endsWith(".aar")) { - def fileName = "org.libsdl.app.aar"; - output.outputFile = new File(outputFile.parent, fileName); + def fileName = "org.libsdl.app.aar" + output.outputFile = new File(outputFile.parent, fileName) } } } @@ -70,8 +71,9 @@ android { dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" } -String getInstalledCmakeVersion() { +static String getInstalledCmakeVersion() { ('cmake --version'.execute().text =~ /cmake version ([0-9.]+)/)[0][1] } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2dfd143..c8a5bc8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,9 +3,8 @@ com.gamemaker.game --> @@ -29,7 +28,7 @@ - + @@ -44,9 +43,8 @@ --> + android:allowBackup="false" + tools:ignore="GoogleAppIndexingWarning"> diff --git a/android/app/src/main/java/io/github/kichikuou/system3/GameActivity.java b/android/app/src/main/java/io/github/kichikuou/system3/GameActivity.java deleted file mode 100644 index 1829b51..0000000 --- a/android/app/src/main/java/io/github/kichikuou/system3/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.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" - }; - } -} diff --git a/android/app/src/main/java/io/github/kichikuou/system3/GameActivity.kt b/android/app/src/main/java/io/github/kichikuou/system3/GameActivity.kt new file mode 100644 index 0000000..9b95544 --- /dev/null +++ b/android/app/src/main/java/io/github/kichikuou/system3/GameActivity.kt @@ -0,0 +1,192 @@ +/* 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.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 { + return arrayOf("hidapi", "SDL2", "system3") + } + + 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 + 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() + } + 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 + } + } +} diff --git a/android/app/src/main/java/io/github/kichikuou/system3/Launcher.kt b/android/app/src/main/java/io/github/kichikuou/system3/Launcher.kt new file mode 100644 index 0000000..cf41710 --- /dev/null +++ b/android/app/src/main/java/io/github/kichikuou/system3/Launcher.kt @@ -0,0 +1,280 @@ +/* Copyright (C) 2020 + * + * 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() + val titles: List + 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 = 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) + } + } +} diff --git a/android/app/src/main/java/io/github/kichikuou/system3/LauncherActivity.java b/android/app/src/main/java/io/github/kichikuou/system3/LauncherActivity.java deleted file mode 100644 index cd4c023..0000000 --- a/android/app/src/main/java/io/github/kichikuou/system3/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.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); - } -} diff --git a/android/app/src/main/java/io/github/kichikuou/system3/LauncherActivity.kt b/android/app/src/main/java/io/github/kichikuou/system3/LauncherActivity.kt new file mode 100644 index 0000000..2d04e20 --- /dev/null +++ b/android/app/src/main/java/io/github/kichikuou/system3/LauncherActivity.kt @@ -0,0 +1,199 @@ +/* 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.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) + } +} diff --git a/android/app/src/main/res/menu/launcher_menu.xml b/android/app/src/main/res/menu/launcher_menu.xml new file mode 100644 index 0000000..d77b28b --- /dev/null +++ b/android/app/src/main/res/menu/launcher_menu.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values-ja/strings.xml b/android/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..4e8d089 --- /dev/null +++ b/android/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,21 @@ + + + system3 + セーブデータをエクスポート + セーブデータをインポート + キャンセル + System3 のファイル (*.dat) が見つかりません。 + ファイルを選択 + エラー + インストール中… + ZIP からインストール + %s を展開中 + ZIP アーカイブ中に save/ フォルダが見つかりません。 + セーブデータのエクスポートに失敗しました。 + エクスポートに成功しました。 + インポートに成功しました。 + 「%s」をアンインストールしますか? + 確認 + この形式の ZIP はサポートしていません。 + ZIP の展開に失敗しました。 + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index edf8b41..3c1f13f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,21 @@ system3 + Export Save Files + Import Save Files + Cancel + Cannot find System3 game files (*.dat). + Choose a file + Error + Installing… + Install from ZIP + Extracting %s + No save/ folder in the ZIP archive. + OK + Failed to export save files. + Imported successfully. + Exported successfully. + Uninstall \"%s\"? + Confirm + This type of ZIP is not supported. + Failed to extract ZIP. 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