Android: Introduce model class for launcher

This fixes a bug where screen rotation during installation causes crash.
This commit is contained in:
kichikuou
2020-01-26 13:01:28 +09:00
parent 31e073203d
commit f6d8611e77
2 changed files with 215 additions and 140 deletions
@@ -0,0 +1,169 @@
/* 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.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<Entry> = arrayListOf()
val titles: List<String>
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<String?> = 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)
}
}
}
@@ -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<String>
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<Entry> = arrayListOf()
val titles: List<String>
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<String?> = 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)