From f6d8611e77b144548007c6e64d5aff10046ad426 Mon Sep 17 00:00:00 2001 From: kichikuou Date: Sun, 26 Jan 2020 09:39:17 +0900 Subject: [PATCH] Android: Introduce model class for launcher This fixes a bug where screen rotation during installation causes crash. --- .../io/github/kichikuou/xsystem35/Launcher.kt | 169 ++++++++++++++++ .../kichikuou/xsystem35/LauncherActivity.kt | 186 +++++------------- 2 files changed, 215 insertions(+), 140 deletions(-) create mode 100644 android/app/src/main/java/io/github/kichikuou/xsystem35/Launcher.kt diff --git a/android/app/src/main/java/io/github/kichikuou/xsystem35/Launcher.kt b/android/app/src/main/java/io/github/kichikuou/xsystem35/Launcher.kt new file mode 100644 index 0000000..bd7be07 --- /dev/null +++ b/android/app/src/main/java/io/github/kichikuou/xsystem35/Launcher.kt @@ -0,0 +1,169 @@ +/* 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.xsystem35 + +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.nio.charset.Charset +import java.util.zip.ZipInputStream +import kotlin.collections.ArrayList + +private var gLauncher: Launcher? = null + +interface LauncherObserver { + fun onInstallProgress(path: String) + fun onInstallSuccess(path: File) + fun onInstallFailure(msgId: Int) +} + +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!! + } + } + + data class Entry(val path: File, val title: String) + val games: ArrayList = arrayListOf() + val titles: List + get() = games.map(Entry::title) + var observer: LauncherObserver? = null + var isInstalling = false + private set + + 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) { + 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) + } + + private fun createDirForGame(): File { + var i = 0 + while (true) { + val f = File(rootDir, i++.toString()) + if (!f.exists() && f.mkdir()) { + return f + } + } + } + + private fun extractFiles(input: InputStream, outDir: File, handler: Handler) { + try { + val playlistWriter = PlaylistWriter() + 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.v("extractFiles", zipEntry.name) + val path = File(outDir, zipEntry.name) + if (zipEntry.isDirectory) + continue + path.parentFile.mkdirs() + handler.sendMessage(handler.obtainMessage(PROGRESS, zipEntry.name)) + val output = FileOutputStream(path).buffered() + zip.copyTo(output) + output.close() + playlistWriter.maybeAdd(path.path) + } + zip.close() + playlistWriter.write(outDir) + handler.sendMessage(handler.obtainMessage(SUCCESS, outDir)) + } 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)) + } + } + + private class PlaylistWriter { + private val audioRegex = """.*?(\d+)\.(wav|mp3|ogg)""".toRegex(RegexOption.IGNORE_CASE) + private val audioFiles: Array = arrayOfNulls(100) + + fun maybeAdd(path: String) { + audioRegex.matchEntire(path)?.let { + val track = it.groupValues[1].toInt() + if (track < audioFiles.size) + audioFiles[track] = path + } + } + + fun write(outDir: File) { + val text = audioFiles.joinToString("\n") { it ?: "" }.trimEnd('\n') + File(outDir, PLAYLIST_FILE).writeText(text) + } + } +} 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 index 76b0f91..675d681 100644 --- a/android/app/src/main/java/io/github/kichikuou/xsystem35/LauncherActivity.kt +++ b/android/app/src/main/java/io/github/kichikuou/xsystem35/LauncherActivity.kt @@ -17,49 +17,47 @@ */ 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 +class LauncherActivity : ListActivity(), AdapterView.OnItemLongClickListener, LauncherObserver { + private lateinit var launcher: Launcher private lateinit var adapter: ArrayAdapter + private var progressDiaglog: ProgressDialogFragment? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - gm = GameManager(filesDir) + launcher = Launcher.getInstance(filesDir) + launcher.observer = this + if (launcher.isInstalling) { + showProgressDialog() + } - val items = gm.titles.toMutableList() + val items = launcher.titles.toMutableList() items.add(getString(R.string.install_from_zip)) adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, items) listAdapter = adapter 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 < gm.games.size) { - startGame(gm.games[position].path) + if (position < launcher.games.size) { + startGame(launcher.games[position].path) } else { val i = Intent(Intent.ACTION_GET_CONTENT) i.type = "application/zip" @@ -68,9 +66,9 @@ class LauncherActivity : ListActivity(), AdapterView.OnItemLongClickListener { } override fun onItemLongClick(a: AdapterView<*>?, v: View?, position: Int, id: Long): Boolean { - if (position < gm.games.size) { + if (position < launcher.games.size) { AlertDialog.Builder(this).setTitle(R.string.uninstall_dialog_title) - .setMessage(getString(R.string.uninstall_dialog_message, gm.games[position].title)) + .setMessage(getString(R.string.uninstall_dialog_message, launcher.games[position].title)) .setPositiveButton(R.string.ok) {_, _ -> uninstall(position)} .setNegativeButton(R.string.cancel) {_, _ -> } .show() @@ -83,28 +81,22 @@ class LauncherActivity : ListActivity(), AdapterView.OnItemLongClickListener { return val uri = data?.data ?: return val input = contentResolver.openInputStream(uri) ?: return - val dialog = ProgressDialogFragment() - dialog.show(fragmentManager, "progress_dialog") + showProgressDialog() + launcher.install(input) + } - @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(getString(R.string.install_progress, msg.obj as String)) - } - SUCCESS -> { - dialog.dismiss() - startGame(msg.obj as File) - } - FAILURE -> { - dialog.dismiss() - errorDialog(msg.obj as Int) - } - } - } - } - gm.install(input, handler) + override fun onInstallProgress(path: String) { + progressDiaglog?.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) { @@ -116,16 +108,26 @@ class LauncherActivity : ListActivity(), AdapterView.OnItemLongClickListener { 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) - i.putExtra(GameActivity.EXTRA_PLAYLIST_FILE, File(path, GameManager.PLAYLIST_FILE).path) + i.putExtra(GameActivity.EXTRA_TITLE_FILE, File(path, Launcher.TITLE_FILE).path) + i.putExtra(GameActivity.EXTRA_PLAYLIST_FILE, File(path, Launcher.PLAYLIST_FILE).path) startActivity(i) } private fun uninstall(id: Int) { - gm.uninstall(id) + launcher.uninstall(id) adapter.remove(adapter.getItem(id)) } + private fun showProgressDialog() { + progressDiaglog = ProgressDialogFragment() + progressDiaglog!!.show(fragmentManager, "progress_dialog") + } + + private fun dismissProgressDialog() { + progressDiaglog?.dismiss() + progressDiaglog = null + } + private fun errorDialog(msgId: Int) { AlertDialog.Builder(this).setTitle(R.string.error_dialog_title) .setMessage(msgId) @@ -149,102 +151,6 @@ class ProgressDialogFragment : DialogFragment() { } } -private class GameManager(private val rootDir: File) { - companion object { - const val TITLE_FILE = "title.txt" - const val PLAYLIST_FILE = "playlist.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 playlistWriter = PlaylistWriter() - 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.v("extractFiles", zipEntry.name) - val path = File(outDir, zipEntry.name) - if (zipEntry.isDirectory) - continue - path.parentFile.mkdirs() - handler.sendMessage(handler.obtainMessage(PROGRESS, zipEntry.name)) - val output = FileOutputStream(path).buffered() - zip.copyTo(output) - output.close() - playlistWriter.maybeAdd(path.path) - } - zip.close() - playlistWriter.write(outDir) - handler.sendMessage(handler.obtainMessage(SUCCESS, outDir)) - } 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)) - } - } - - private class PlaylistWriter { - private val audioRegex = """.*?(\d+)\.(wav|mp3|ogg)""".toRegex(RegexOption.IGNORE_CASE) - private val audioFiles: Array = arrayOfNulls(100) - - fun maybeAdd(path: String) { - audioRegex.matchEntire(path)?.let { - val track = it.groupValues[1].toInt() - if (track < audioFiles.size) - audioFiles[track] = path - } - } - - fun write(outDir: File) { - val text = audioFiles.joinToString("\n") { it ?: "" }.trimEnd('\n') - File(outDir, PLAYLIST_FILE).writeText(text) - } - } -} - private fun findGameRoot(path: File): File? { path.walkTopDown().forEach { val name = it.name.toLowerCase(Locale.US)