mirror of
https://github.com/kichikuou/xsystem35-sdl2.git
synced 2026-09-22 22:48:08 +03:00
Android: Split launcher storage and install logic
Move ZIP installation and file-store responsibilities out of Launcher. Launcher now coordinates install state and delegates game scanning, save-data operations, and ZIP extraction to focused helper classes.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
/* Copyright (C) 2026 <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.os.Build
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.UTFDataFormatException
|
||||
import java.nio.charset.Charset
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
class GameInstaller(private val store: GameStore) {
|
||||
suspend fun installZip(input: InputStream, progressCallback: suspend (String) -> Unit): File {
|
||||
val dir = store.createDirForGame()
|
||||
var committed = false
|
||||
try {
|
||||
val gameDir = extractFiles(input, dir, progressCallback)
|
||||
committed = true
|
||||
return gameDir
|
||||
} 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
|
||||
): File {
|
||||
val configWriter = GameConfigWriter()
|
||||
val hadDecodeError = forEachZipEntrySuspending(input) { zipEntry, zip ->
|
||||
Log.i("extractFiles", zipEntry.name)
|
||||
if (zipEntry.isDirectory)
|
||||
return@forEachZipEntrySuspending
|
||||
val resolvedPath = resolveOutputPath(outDir, zipEntry.name)
|
||||
val path = resolvedPath.file
|
||||
path.parentFile?.mkdirs()
|
||||
progressCallback(resolvedPath.relativePath)
|
||||
FileOutputStream(path).buffered().use {
|
||||
zip.copyTo(it)
|
||||
}
|
||||
configWriter.maybeAdd(resolvedPath.relativePath)
|
||||
}
|
||||
if (!configWriter.ready) {
|
||||
if (hadDecodeError)
|
||||
throw InstallFailureException(R.string.unsupported_zip)
|
||||
throw InstallFailureException(R.string.cannot_find_ald)
|
||||
}
|
||||
configWriter.write(outDir)
|
||||
return configWriter.gameDir?.let { File(outDir, it) } ?: outDir
|
||||
}
|
||||
}
|
||||
|
||||
internal class InstallFailureException(val msgId: Int) : Exception()
|
||||
|
||||
// A helper class which generates GAMEDIR_FILE and PLAYLIST_FILE.
|
||||
private class GameConfigWriter {
|
||||
var ready = false
|
||||
private set
|
||||
var gameDir: String? = null
|
||||
private set
|
||||
private val aldRegex = """.*?s[a-z]\.ald""".toRegex(RegexOption.IGNORE_CASE)
|
||||
private val audioRegex = """((\d+).*|.*?(\d+))\.(wav|mp3|ogg)""".toRegex(RegexOption.IGNORE_CASE)
|
||||
private val audioFiles: Array<String?> = arrayOfNulls(100)
|
||||
|
||||
fun maybeAdd(path: String) {
|
||||
val name = File(path).name
|
||||
aldRegex.matchEntire(name)?.let {
|
||||
gameDir = File(path).parent
|
||||
ready = true
|
||||
}
|
||||
audioRegex.matchEntire(name)?.let {
|
||||
val track = it.groupValues[2].toIntOrNull() ?: it.groupValues[3].toInt()
|
||||
if (0 < track && track <= audioFiles.size)
|
||||
audioFiles[track - 1] = path
|
||||
}
|
||||
}
|
||||
|
||||
fun write(outDir: File) {
|
||||
// Generate GAMEDIR_FILE
|
||||
gameDir?.let {
|
||||
File(outDir, Launcher.GAMEDIR_FILE).writeText(it)
|
||||
}
|
||||
// Generate PLAYLIST_FILE
|
||||
val absGameDir = gameDir?.let { File(outDir, it) } ?: outDir
|
||||
val playlistFile = File(absGameDir, Launcher.PLAYLIST_FILE)
|
||||
if (!playlistFile.exists()) {
|
||||
val prefixToRemove = gameDir?.let { "$it/" } ?: ""
|
||||
val playlist = audioFiles.joinToString("\n") {
|
||||
it?.removePrefix(prefixToRemove) ?: ""
|
||||
}.trimEnd('\n')
|
||||
playlistFile.writeText(playlist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @property file Safe canonical output path.
|
||||
* @property relativePath Path relative to the install root after canonicalization.
|
||||
*/
|
||||
internal data class ResolvedOutputPath(val file: File, val relativePath: String)
|
||||
|
||||
internal fun resolveOutputPath(baseDir: File, relativePath: String): ResolvedOutputPath {
|
||||
val canonicalBase = baseDir.canonicalFile
|
||||
val file = File(canonicalBase, relativePath).canonicalFile
|
||||
if (!isFileInsideDirectory(file, canonicalBase)) {
|
||||
throw IOException("Output path is outside target directory: $relativePath")
|
||||
}
|
||||
val basePath = canonicalBase.path + File.separator
|
||||
val canonicalRelativePath = file.path.removePrefix(basePath)
|
||||
return ResolvedOutputPath(file, canonicalRelativePath)
|
||||
}
|
||||
|
||||
private fun isFileInsideDirectory(file: File, directory: File): Boolean {
|
||||
return file.path.startsWith(directory.path + File.separator)
|
||||
}
|
||||
|
||||
internal fun forEachZipEntry(input: InputStream, action: (ZipEntry, ZipInputStream) -> Unit): Boolean =
|
||||
runBlocking {
|
||||
forEachZipEntrySuspending(input) { zipEntry, zip ->
|
||||
action(zipEntry, zip)
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun forEachZipEntrySuspending(
|
||||
input: InputStream,
|
||||
action: suspend (ZipEntry, ZipInputStream) -> Unit
|
||||
): Boolean {
|
||||
val zip = if (Build.VERSION.SDK_INT >= 24) {
|
||||
ZipInputStream(input.buffered(), Charset.forName("Shift_JIS"))
|
||||
} else {
|
||||
ZipInputStream(input.buffered())
|
||||
}
|
||||
var hadDecodeError = false
|
||||
zip.use {
|
||||
while (true) {
|
||||
try {
|
||||
val zipEntry = zip.nextEntry ?: break
|
||||
action(zipEntry, zip)
|
||||
} catch (e: UTFDataFormatException) {
|
||||
// Attempted to read Shift_JIS zip in Android < 7
|
||||
Log.w("forEachZipEntry", "UTFDataFormatException: skipping a zip entry")
|
||||
zip.closeEntry()
|
||||
hadDecodeError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return hadDecodeError
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/* Copyright (C) 2026 <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.util.Log
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.io.UTFDataFormatException
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class GameStore(private val rootDir: File) {
|
||||
data class Entry(val path: File, val title: String, val timestamp: Long)
|
||||
|
||||
private val gameList = arrayListOf<Entry>()
|
||||
val games: List<Entry>
|
||||
get() = gameList
|
||||
val titles: List<String>
|
||||
get() = gameList.map(Entry::title)
|
||||
|
||||
init {
|
||||
updateGameList()
|
||||
}
|
||||
|
||||
fun uninstall(id: Int) {
|
||||
gameList[id].path.deleteRecursively()
|
||||
gameList.removeAt(id)
|
||||
}
|
||||
|
||||
fun updateGameList() {
|
||||
var saveDirFound = false
|
||||
gameList.clear()
|
||||
for (path in rootDir.listFiles() ?: emptyArray()) {
|
||||
if (!path.isDirectory)
|
||||
continue
|
||||
if (path.name == Launcher.SAVE_DIR) {
|
||||
saveDirFound = true
|
||||
continue
|
||||
}
|
||||
try {
|
||||
val gameDirFile = File(path, Launcher.GAMEDIR_FILE)
|
||||
val gamePath = if (gameDirFile.exists()) File(path, gameDirFile.readText()) else path
|
||||
val titleFile = File(gamePath, Launcher.TITLE_FILE)
|
||||
val title = titleFile.readText()
|
||||
gameList.add(Entry(gamePath, title, titleFile.lastModified()))
|
||||
migratePlaylist(path)
|
||||
} catch (e: IOException) {
|
||||
// Incomplete game installation. Delete it.
|
||||
path.deleteRecursively()
|
||||
}
|
||||
}
|
||||
gameList.sortByDescending(Entry::timestamp)
|
||||
if (!saveDirFound) {
|
||||
File(rootDir, Launcher.SAVE_DIR).mkdir()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
ZipOutputStream(output.buffered()).use { zip ->
|
||||
for (path in File(rootDir, Launcher.SAVE_DIR).listFiles() ?: emptyArray()) {
|
||||
if (path.isDirectory || path.name.endsWith(".asd."))
|
||||
continue
|
||||
val pathInZip = "${Launcher.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
|
||||
val path = resolveOutputPath(rootDir, zipEntry.name).file
|
||||
Log.i("importSaveData", zipEntry.name)
|
||||
FileOutputStream(path).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
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSaveData(): Boolean {
|
||||
var success = true
|
||||
File(rootDir, Launcher.SAVE_DIR).listFiles()?.forEach { file ->
|
||||
if (!file.deleteRecursively()) success = false
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
// Xsystem35-sdl2 2.3.0 - 2.11.1 used playlist2.txt. Rename it to playlist.txt.
|
||||
private fun migratePlaylist(dir: File) {
|
||||
val oldPlaylist = File(dir, Launcher.OLD_PLAYLIST_FILE)
|
||||
if (oldPlaylist.exists()) {
|
||||
oldPlaylist.renameTo(File(dir, Launcher.PLAYLIST_FILE))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,20 +17,14 @@
|
||||
*/
|
||||
package io.github.kichikuou.xsystem35
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
private var gLauncher: Launcher? = null
|
||||
|
||||
@@ -50,7 +44,7 @@ sealed class InstallState {
|
||||
|
||||
data class InstallResult(val path: File, val archiveName: String?)
|
||||
|
||||
class Launcher private constructor(private val rootDir: File) {
|
||||
class Launcher private constructor(rootDir: File) {
|
||||
companion object {
|
||||
const val SAVE_DIR = "save"
|
||||
const val TITLE_FILE = "title.txt"
|
||||
@@ -70,21 +64,19 @@ class Launcher private constructor(private val rootDir: File) {
|
||||
}
|
||||
}
|
||||
|
||||
data class Entry(val path: File, val title: String, val timestamp: Long)
|
||||
val games = arrayListOf<Entry>()
|
||||
private val store = GameStore(rootDir)
|
||||
private val installer = GameInstaller(store)
|
||||
val games: List<GameStore.Entry>
|
||||
get() = store.games
|
||||
val titles: List<String>
|
||||
get() = games.map(Entry::title)
|
||||
get() = store.titles
|
||||
var observer: LauncherObserver? = null
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var installJob: Job? = null
|
||||
var installState: InstallState = InstallState.Idle
|
||||
private set
|
||||
|
||||
init {
|
||||
updateGameList()
|
||||
}
|
||||
|
||||
fun install(input: InputStream, archiveName: String?) {
|
||||
fun installZip(input: InputStream, archiveName: String?) {
|
||||
if (installJob?.isActive == true) {
|
||||
input.close()
|
||||
return
|
||||
@@ -92,7 +84,7 @@ class Launcher private constructor(private val rootDir: File) {
|
||||
startInstallJob {
|
||||
val gameDir = withContext(Dispatchers.IO) {
|
||||
input.use {
|
||||
extractZipTransactionally(it) { msg ->
|
||||
installer.installZip(it) { msg ->
|
||||
withContext(Dispatchers.Main) {
|
||||
setInstallProgress(msg)
|
||||
}
|
||||
@@ -121,23 +113,6 @@ class Launcher private constructor(private val rootDir: File) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun extractZipTransactionally(
|
||||
input: InputStream,
|
||||
progressCallback: suspend (String) -> Unit
|
||||
): File {
|
||||
val dir = createDirForGame()
|
||||
var committed = false
|
||||
try {
|
||||
val gameDir = extractFiles(input, dir, progressCallback)
|
||||
committed = true
|
||||
return gameDir
|
||||
} finally {
|
||||
if (!committed && !dir.deleteRecursively()) {
|
||||
Log.w("launcher", "Failed to delete incomplete install directory: $dir")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeInstallResult() {
|
||||
if (installState is InstallState.Succeeded || installState is InstallState.Failed) {
|
||||
installState = InstallState.Idle
|
||||
@@ -160,229 +135,25 @@ class Launcher private constructor(private val rootDir: File) {
|
||||
}
|
||||
|
||||
fun uninstall(id: Int) {
|
||||
games[id].path.deleteRecursively()
|
||||
games.removeAt(id)
|
||||
store.uninstall(id)
|
||||
observer?.onGameListChange()
|
||||
}
|
||||
|
||||
private fun updateGameList() {
|
||||
var saveDirFound = false
|
||||
games.clear()
|
||||
for (path in rootDir.listFiles() ?: emptyArray()) {
|
||||
if (!path.isDirectory)
|
||||
continue
|
||||
if (path.name == SAVE_DIR) {
|
||||
saveDirFound = true
|
||||
continue
|
||||
}
|
||||
try {
|
||||
val gameDirFile = File(path, GAMEDIR_FILE)
|
||||
val gamePath = if (gameDirFile.exists()) File(path, gameDirFile.readText()) else path
|
||||
val titleFile = File(gamePath, TITLE_FILE)
|
||||
val title = titleFile.readText()
|
||||
games.add(Entry(gamePath, title, titleFile.lastModified()))
|
||||
migratePlaylist(path)
|
||||
} catch (e: IOException) {
|
||||
// Incomplete game installation. Delete it.
|
||||
path.deleteRecursively()
|
||||
}
|
||||
}
|
||||
games.sortByDescending(Entry::timestamp)
|
||||
if (!saveDirFound) {
|
||||
File(rootDir, SAVE_DIR).mkdir()
|
||||
}
|
||||
store.updateGameList()
|
||||
observer?.onGameListChange()
|
||||
}
|
||||
|
||||
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) {
|
||||
ZipOutputStream(output.buffered()).use { zip ->
|
||||
for (path in File(rootDir, SAVE_DIR).listFiles() ?: emptyArray()) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
store.exportSaveData(output)
|
||||
}
|
||||
|
||||
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
|
||||
val path = resolveOutputPath(rootDir, zipEntry.name).file
|
||||
Log.i("importSaveData", zipEntry.name)
|
||||
FileOutputStream(path).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 suspend fun extractFiles(
|
||||
input: InputStream,
|
||||
outDir: File,
|
||||
progressCallback: suspend (String) -> Unit
|
||||
): File {
|
||||
val configWriter = GameConfigWriter()
|
||||
val hadDecodeError = forEachZipEntrySuspending(input) { zipEntry, zip ->
|
||||
Log.i("extractFiles", zipEntry.name)
|
||||
if (zipEntry.isDirectory)
|
||||
return@forEachZipEntrySuspending
|
||||
val resolvedPath = resolveOutputPath(outDir, zipEntry.name)
|
||||
val path = resolvedPath.file
|
||||
path.parentFile?.mkdirs()
|
||||
progressCallback(resolvedPath.relativePath)
|
||||
FileOutputStream(path).buffered().use {
|
||||
zip.copyTo(it)
|
||||
}
|
||||
configWriter.maybeAdd(resolvedPath.relativePath)
|
||||
}
|
||||
if (!configWriter.ready) {
|
||||
if (hadDecodeError)
|
||||
throw InstallFailureException(R.string.unsupported_zip)
|
||||
throw InstallFailureException(R.string.cannot_find_ald)
|
||||
}
|
||||
configWriter.write(outDir)
|
||||
return configWriter.gameDir?.let { File(outDir, it) } ?: outDir
|
||||
}
|
||||
|
||||
// Xsystem35-sdl2 2.3.0 - 2.11.1 used playlist2.txt. Rename it to playlist.txt.
|
||||
private fun migratePlaylist(dir: File) {
|
||||
val oldPlaylist = File(dir, OLD_PLAYLIST_FILE)
|
||||
if (oldPlaylist.exists()) {
|
||||
oldPlaylist.renameTo(File(dir, PLAYLIST_FILE))
|
||||
}
|
||||
}
|
||||
|
||||
class InstallFailureException(val msgId: Int) : Exception()
|
||||
|
||||
// A helper class which generates GAMEDIR_FILE and PLAYLIST_FILE.
|
||||
private class GameConfigWriter {
|
||||
var ready = false
|
||||
private set
|
||||
var gameDir: String? = null
|
||||
private set
|
||||
private val aldRegex = """.*?s[a-z]\.ald""".toRegex(RegexOption.IGNORE_CASE)
|
||||
private val audioRegex = """((\d+).*|.*?(\d+))\.(wav|mp3|ogg)""".toRegex(RegexOption.IGNORE_CASE)
|
||||
private val audioFiles: Array<String?> = arrayOfNulls(100)
|
||||
|
||||
fun maybeAdd(path: String) {
|
||||
val name = File(path).name
|
||||
aldRegex.matchEntire(name)?.let {
|
||||
gameDir = File(path).parent
|
||||
ready = true
|
||||
}
|
||||
audioRegex.matchEntire(name)?.let {
|
||||
val track = it.groupValues[2].toIntOrNull() ?: it.groupValues[3].toInt()
|
||||
if (0 < track && track <= audioFiles.size)
|
||||
audioFiles[track - 1] = path
|
||||
}
|
||||
}
|
||||
|
||||
fun write(outDir: File) {
|
||||
// Generate GAMEDIR_FILE
|
||||
gameDir?.let {
|
||||
File(outDir, GAMEDIR_FILE).writeText(it)
|
||||
}
|
||||
// Generate PLAYLIST_FILE
|
||||
val absGameDir = gameDir?.let { File(outDir, it) } ?: outDir
|
||||
val playlistFile = File(absGameDir, PLAYLIST_FILE)
|
||||
if (!playlistFile.exists()) {
|
||||
val prefixToRemove = gameDir?.let { "$it/" } ?: ""
|
||||
val playlist = audioFiles.joinToString("\n") {
|
||||
it?.removePrefix(prefixToRemove) ?: ""
|
||||
}.trimEnd('\n')
|
||||
playlistFile.writeText(playlist)
|
||||
}
|
||||
}
|
||||
return store.importSaveData(input)
|
||||
}
|
||||
|
||||
fun clearSaveData(): Boolean {
|
||||
var success = true
|
||||
File(rootDir, SAVE_DIR).listFiles()?.forEach { file ->
|
||||
if (!file.deleteRecursively()) success = false
|
||||
}
|
||||
return success
|
||||
return store.clearSaveData()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @property file Safe canonical output path.
|
||||
* @property relativePath Path relative to the install root after canonicalization.
|
||||
*/
|
||||
private data class ResolvedOutputPath(val file: File, val relativePath: String)
|
||||
|
||||
private fun resolveOutputPath(baseDir: File, relativePath: String): ResolvedOutputPath {
|
||||
val canonicalBase = baseDir.canonicalFile
|
||||
val file = File(canonicalBase, relativePath).canonicalFile
|
||||
if (!isFileInsideDirectory(file, canonicalBase)) {
|
||||
throw IOException("Output path is outside target directory: $relativePath")
|
||||
}
|
||||
val basePath = canonicalBase.path + File.separator
|
||||
val canonicalRelativePath = file.path.removePrefix(basePath)
|
||||
return ResolvedOutputPath(file, canonicalRelativePath)
|
||||
}
|
||||
|
||||
private fun isFileInsideDirectory(file: File, directory: File): Boolean {
|
||||
return file.path.startsWith(directory.path + File.separator)
|
||||
}
|
||||
|
||||
private fun forEachZipEntry(input: InputStream, action: (ZipEntry, ZipInputStream) -> Unit): Boolean =
|
||||
runBlocking {
|
||||
forEachZipEntrySuspending(input) { zipEntry, zip ->
|
||||
action(zipEntry, zip)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun forEachZipEntrySuspending(
|
||||
input: InputStream,
|
||||
action: suspend (ZipEntry, ZipInputStream) -> Unit
|
||||
): Boolean {
|
||||
val zip = if (Build.VERSION.SDK_INT >= 24) {
|
||||
ZipInputStream(input.buffered(), Charset.forName("Shift_JIS"))
|
||||
} else {
|
||||
ZipInputStream(input.buffered())
|
||||
}
|
||||
var hadDecodeError = false
|
||||
zip.use {
|
||||
while (true) {
|
||||
try {
|
||||
val zipEntry = zip.nextEntry ?: break
|
||||
action(zipEntry, zip)
|
||||
} catch (e: UTFDataFormatException) {
|
||||
// Attempted to read Shift_JIS zip in Android < 7
|
||||
Log.w("forEachZipEntry", "UTFDataFormatException: skipping a zip entry")
|
||||
zip.closeEntry()
|
||||
hadDecodeError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return hadDecodeError
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ class LauncherActivity : Activity(), LauncherObserver {
|
||||
when (requestCode) {
|
||||
INSTALL_REQUEST -> {
|
||||
val input = contentResolver.openInputStream(uri) ?: return
|
||||
launcher.install(input, getArchiveName(uri))
|
||||
launcher.installZip(input, getArchiveName(uri))
|
||||
renderInstallState(launcher.installState)
|
||||
}
|
||||
SAVEDATA_EXPORT_REQUEST -> try {
|
||||
|
||||
Reference in New Issue
Block a user