Android: Keep launcher install state in Launcher

Move ZIP install jobs from GlobalScope to a Launcher-owned coroutine
scope and store progress, success, and failure in InstallState. This
lets a recreated LauncherActivity render the current install state and
consume completed results once, even when observer callbacks happened
while the activity was absent.
This commit is contained in:
kichikuou
2026-07-19 10:58:22 +09:00
parent df6f1bac73
commit 88d6d0a743
2 changed files with 118 additions and 45 deletions
@@ -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<String>
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 {
@@ -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<ListView>(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<TextView>(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<TextView>(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<TextView>(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<TextView>(R.id.text)?.text = getString(R.string.install_progress, it)
}
}