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 index 6412142..04ae33f 100644 --- a/android/app/src/main/java/io/github/kichikuou/system3/Launcher.kt +++ b/android/app/src/main/java/io/github/kichikuou/system3/Launcher.kt @@ -19,10 +19,12 @@ package io.github.kichikuou.system3 import android.os.Build import android.util.Log -import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.io.* import java.nio.charset.Charset @@ -40,6 +42,13 @@ interface LauncherObserver { fun onInstallFailure(msgId: Int) } +sealed class InstallState { + object Idle : InstallState() + data class Installing(val progress: String?) : InstallState() + data class Succeeded(val path: File) : InstallState() + data class Failed(val msgId: Int) : InstallState() +} + private const val SAVE_DIR = "save" class Launcher private constructor(private val rootDir: File) { @@ -64,7 +73,9 @@ class Launcher private constructor(private val rootDir: File) { val titles: List get() = games.map(Entry::title) var observer: LauncherObserver? = null - var isInstalling = false + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var installJob: Job? = null + var installState: InstallState = InstallState.Idle private set val saveDir: File get() = File(rootDir, SAVE_DIR) @@ -73,30 +84,54 @@ class Launcher private constructor(private val rootDir: File) { updateGameList() } - @OptIn(DelicateCoroutinesApi::class) fun install(input: InputStream) { - val dir = createDirForGame() - isInstalling = true - GlobalScope.launch(Dispatchers.Main) { + if (installJob?.isActive == true) { + input.close() + return + } + installState = InstallState.Installing(null) + installJob = scope.launch { try { - withContext(Dispatchers.IO) { - extractFiles(input, dir) { msg -> - GlobalScope.launch(Dispatchers.Main) { - observer?.onInstallProgress(msg) + val dir = withContext(Dispatchers.IO) { + input.use { + extractFilesTransactionally(it) { msg -> + withContext(Dispatchers.Main) { + setInstallProgress(msg) + } } } } - observer?.onInstallSuccess(dir) + setInstallSucceeded(dir) } catch (e: InstallFailureException) { - observer?.onInstallFailure(e.msgId) + setInstallFailed(e.msgId) } catch (e: Exception) { Log.e("launcher", "Failed to extract ZIP", e) - observer?.onInstallFailure(R.string.zip_extraction_error) + setInstallFailed(R.string.zip_extraction_error) } - isInstalling = false } } + fun consumeInstallResult() { + if (installState is InstallState.Succeeded || installState is InstallState.Failed) { + installState = InstallState.Idle + } + } + + private fun setInstallProgress(progress: String) { + installState = InstallState.Installing(progress) + observer?.onInstallProgress(progress) + } + + private fun setInstallSucceeded(path: File) { + installState = InstallState.Succeeded(path) + observer?.onInstallSuccess(path) + } + + private fun setInstallFailed(msgId: Int) { + installState = InstallState.Failed(msgId) + observer?.onInstallFailure(msgId) + } + fun uninstall(id: Int) { games[id].path.deleteRecursively() games.removeAt(id) @@ -180,13 +215,34 @@ class Launcher private constructor(private val rootDir: File) { } } - private fun extractFiles(input: InputStream, outDir: File, progressCallback: (String) -> Unit) { + private suspend fun extractFilesTransactionally( + input: InputStream, + progressCallback: suspend (String) -> Unit + ): File { + val dir = createDirForGame() + var committed = false + try { + extractFiles(input, dir, progressCallback) + committed = true + return dir + } finally { + if (!committed && !dir.deleteRecursively()) { + Log.w("launcher", "Failed to delete incomplete install directory: $dir") + } + } + } + + private suspend fun extractFiles( + input: InputStream, + outDir: File, + progressCallback: suspend (String) -> Unit + ) { val configWriter = GameConfigWriter() - forEachZipEntry(input) { zipEntry, zip -> + forEachZipEntrySuspending(input) { zipEntry, zip -> Log.i("extractFiles", zipEntry.name) val entryName = File(zipEntry.name).name if (zipEntry.isDirectory) - return@forEachZipEntry + return@forEachZipEntrySuspending progressCallback(entryName) FileOutputStream(resolveOutputPath(outDir, entryName)).buffered().use { zip.copyTo(it) @@ -236,7 +292,17 @@ private fun resolveOutputPath(baseDir: File, relativePath: String): File { return file } -private fun forEachZipEntry(input: InputStream, action: (ZipEntry, ZipInputStream) -> Unit) { +private fun forEachZipEntry(input: InputStream, action: (ZipEntry, ZipInputStream) -> Unit) = + runBlocking { + forEachZipEntrySuspending(input) { zipEntry, zip -> + action(zipEntry, zip) + } + } + +private suspend fun forEachZipEntrySuspending( + input: InputStream, + action: suspend (ZipEntry, ZipInputStream) -> Unit +) { val zip = if (Build.VERSION.SDK_INT >= 24) { ZipInputStream(input.buffered(), Charset.forName("Shift_JIS")) } else { 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 index e4c286c..0557269 100644 --- a/android/app/src/main/java/io/github/kichikuou/system3/LauncherActivity.kt +++ b/android/app/src/main/java/io/github/kichikuou/system3/LauncherActivity.kt @@ -34,7 +34,6 @@ 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 -private const val STATE_PROGRESS_TEXT = "progressText" class LauncherActivity : Activity(), LauncherObserver { private lateinit var launcher: Launcher @@ -46,9 +45,7 @@ class LauncherActivity : Activity(), LauncherObserver { launcher = Launcher.getInstance(filesDir) launcher.observer = this - if (launcher.isInstalling) { - showProgressDialog(savedInstanceState) - } + renderInstallState(launcher.installState) onGameListChange() val listView = findViewById(R.id.list) @@ -68,13 +65,6 @@ class LauncherActivity : Activity(), LauncherObserver { super.onDestroy() } - override fun onSaveInstanceState(outState: Bundle) { - progressDialog?.let { - outState.putCharSequence(STATE_PROGRESS_TEXT, it.findViewById(R.id.text).text) - } - super.onSaveInstanceState(outState) - } - private fun onListItemClick(position: Int) { startGame(launcher.games[position].path) } @@ -137,8 +127,8 @@ class LauncherActivity : Activity(), LauncherObserver { when (requestCode) { INSTALL_REQUEST -> { val input = contentResolver.openInputStream(uri) ?: return - showProgressDialog() launcher.install(input) + renderInstallState(launcher.installState) } SAVEDATA_EXPORT_REQUEST -> try { launcher.exportSaveData(contentResolver.openOutputStream(uri)!!) @@ -167,17 +157,15 @@ class LauncherActivity : Activity(), LauncherObserver { } override fun onInstallProgress(path: String) { - progressDialog?.findViewById(R.id.text)?.text = getString(R.string.install_progress, path) + renderInstallState(launcher.installState) } override fun onInstallSuccess(path: File) { - dismissProgressDialog() - startGame(path) + renderInstallState(launcher.installState) } override fun onInstallFailure(msgId: Int) { - dismissProgressDialog() - errorDialog(msgId) + renderInstallState(launcher.installState) } private fun startGame(path: File) { @@ -192,16 +180,35 @@ class LauncherActivity : Activity(), LauncherObserver { launcher.uninstall(id) } - private fun showProgressDialog(savedInstanceState: Bundle? = null) { - progressDialog = Dialog(this) - progressDialog!!.apply { - setTitle(R.string.install_dialog_title) - setCancelable(false) - setContentView(R.layout.progress_dialog) - savedInstanceState?.let { - findViewById(R.id.text)?.text = it.getCharSequence(STATE_PROGRESS_TEXT) + private fun renderInstallState(state: InstallState) { + when (state) { + InstallState.Idle -> dismissProgressDialog() + is InstallState.Installing -> showProgressDialog(state.progress) + is InstallState.Succeeded -> { + dismissProgressDialog() + startGame(state.path) + launcher.consumeInstallResult() } - show() + is InstallState.Failed -> { + dismissProgressDialog() + errorDialog(state.msgId) + launcher.consumeInstallResult() + } + } + } + + private fun showProgressDialog(progress: String? = null) { + if (progressDialog == null) { + progressDialog = Dialog(this) + progressDialog!!.apply { + setTitle(R.string.install_dialog_title) + setCancelable(false) + setContentView(R.layout.progress_dialog) + show() + } + } + progress?.let { + progressDialog?.findViewById(R.id.text)?.text = getString(R.string.install_progress, it) } }