diff --git a/android/build.gradle.kts b/android/build.gradle.kts index b26f93db75..08ed1917a9 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -20,7 +20,9 @@ android { } } packagingOptions { - resources.excludes.add("META-INF/robovm/ios/robovm.xml") + resources.excludes += "META-INF/robovm/ios/robovm.xml" + // part of kotlinx-coroutines-android, should not go into the apk + resources.excludes += "DebugProbesKt.bin" } defaultConfig { applicationId = "com.unciv.app" diff --git a/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt b/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt index 7bb2aa03b5..10917c5813 100644 --- a/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt +++ b/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt @@ -15,9 +15,10 @@ import androidx.work.* import com.badlogic.gdx.backends.android.AndroidApplication import com.unciv.logic.GameInfo import com.unciv.logic.GameSaver -import com.unciv.logic.multiplayer.FileStorageRateLimitReached +import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.models.metadata.GameSettings -import com.unciv.logic.multiplayer.OnlineMultiplayer +import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver +import kotlinx.coroutines.runBlocking import java.io.FileNotFoundException import java.io.PrintWriter import java.io.StringWriter @@ -234,7 +235,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame } } - override fun doWork(): Result { + override fun doWork(): Result = runBlocking { val showPersistNotific = inputData.getBoolean(PERSISTENT_NOTIFICATION_ENABLED, true) val configuredDelay = inputData.getInt(CONFIGURED_DELAY, 5) val fileStorage = inputData.getString(FILE_STORAGE) @@ -253,7 +254,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame continue try { - val gamePreview = OnlineMultiplayer(fileStorage).tryDownloadGamePreview(gameId) + val gamePreview = OnlineMultiplayerGameSaver(fileStorage).tryDownloadGamePreview(gameId) val currentTurnPlayer = gamePreview.getCivilization(gamePreview.currentPlayer) //Save game so MultiplayerScreen gets updated @@ -302,7 +303,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame with(NotificationManagerCompat.from(applicationContext)) { cancel(NOTIFICATION_ID_SERVICE) } - return Result.failure() + return@runBlocking Result.failure() } else { if (showPersistNotific) { showPersistentNotification(applicationContext, applicationContext.resources.getString(R.string.Notify_Error_Retrying), configuredDelay.toString()) } @@ -313,9 +314,9 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame enqueue(applicationContext, 1, inputDataFailIncrease) } } catch (outOfMemory: OutOfMemoryError){ // no point in trying multiple times if this was an oom error - return Result.failure() + return@runBlocking Result.failure() } - return Result.success() + return@runBlocking Result.success() } private fun getStackTraceString(ex: Exception): String { diff --git a/build.gradle.kts b/build.gradle.kts index 189299d762..67b38c7b7c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -93,6 +93,7 @@ project(":android") { dependencies { "implementation"(project(":core")) "implementation"("com.badlogicgames.gdx:gdx-backend-android:$gdxVersion") + "implementation"("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1") natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-armeabi-v7a") natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-arm64-v8a") natives("com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86") @@ -119,6 +120,7 @@ project(":core") { dependencies { "implementation"("com.badlogicgames.gdx:gdx:$gdxVersion") + "implementation"("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1") } diff --git a/core/src/com/unciv/MainMenuScreen.kt b/core/src/com/unciv/MainMenuScreen.kt index 67d1f65dfe..0d12f14ed2 100644 --- a/core/src/com/unciv/MainMenuScreen.kt +++ b/core/src/com/unciv/MainMenuScreen.kt @@ -20,7 +20,7 @@ import com.unciv.ui.MultiplayerScreen import com.unciv.ui.mapeditor.* import com.unciv.models.metadata.GameSetupInfo import com.unciv.ui.civilopedia.CivilopediaScreen -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.newgamescreen.NewGameScreen @@ -73,12 +73,12 @@ class MainMenuScreen: BaseScreen() { // will not exist unless we reset the ruleset and images ImageGetter.ruleset = RulesetCache.getVanillaRuleset() - crashHandlingThread(name = "ShowMapBackground") { + launchCrashHandling("ShowMapBackground") { val newMap = MapGenerator(RulesetCache.getVanillaRuleset()) .generateMap(MapParameters().apply { mapSize = MapSizeNew(MapSize.Small); type = MapType.default }) postCrashHandlingRunnable { // for GL context ImageGetter.setNewRuleset(RulesetCache.getVanillaRuleset()) - val mapHolder = EditorMapHolder(this, newMap) {} + val mapHolder = EditorMapHolder(this@MainMenuScreen, newMap) {} backgroundTable.addAction(Actions.sequence( Actions.fadeOut(0f), Actions.run { @@ -167,12 +167,12 @@ class MainMenuScreen: BaseScreen() { val loadingPopup = Popup(this) loadingPopup.addGoodSizedLabel("Loading...") loadingPopup.open() - crashHandlingThread { + launchCrashHandling("autoLoadGame") { // Load game from file to class on separate thread to avoid ANR... fun outOfMemory() { postCrashHandlingRunnable { loadingPopup.close() - ToastPopup("Not enough memory on phone to load game!", this) + ToastPopup("Not enough memory on phone to load game!", this@MainMenuScreen) } } @@ -181,7 +181,7 @@ class MainMenuScreen: BaseScreen() { savedGame = GameSaver.loadGameByName(GameSaver.autoSaveFileName) } catch (oom: OutOfMemoryError) { outOfMemory() - return@crashHandlingThread + return@launchCrashHandling } catch (ex: Exception) { // silent fail if we can't read the autosave for any reason - try to load the last autosave by turn number first // This can help for situations when the autosave is corrupted try { @@ -191,13 +191,13 @@ class MainMenuScreen: BaseScreen() { GameSaver.loadGameFromFile(autosaves.maxByOrNull { it.lastModified() }!!) } catch (oom: OutOfMemoryError) { // The autosave could have oom problems as well... smh outOfMemory() - return@crashHandlingThread + return@launchCrashHandling } catch (ex: Exception) { postCrashHandlingRunnable { loadingPopup.close() - ToastPopup("Cannot resume game!", this) + ToastPopup("Cannot resume game!", this@MainMenuScreen) } - return@crashHandlingThread + return@launchCrashHandling } } @@ -215,14 +215,14 @@ class MainMenuScreen: BaseScreen() { private fun quickstartNewGame() { ToastPopup("Working...", this) val errorText = "Cannot start game with the default new game parameters!" - crashHandlingThread { + launchCrashHandling("QuickStart") { val newGame: GameInfo // Can fail when starting the game... try { newGame = GameStarter.startNewGame(GameSetupInfo.fromSettings("Chieftain")) } catch (ex: Exception) { - postCrashHandlingRunnable { ToastPopup(errorText, this) } - return@crashHandlingThread + postCrashHandlingRunnable { ToastPopup(errorText, this@MainMenuScreen) } + return@launchCrashHandling } // ...or when loading the game @@ -230,9 +230,9 @@ class MainMenuScreen: BaseScreen() { try { game.loadGame(newGame) } catch (outOfMemory: OutOfMemoryError) { - ToastPopup("Not enough memory on phone to load game!", this) + ToastPopup("Not enough memory on phone to load game!", this@MainMenuScreen) } catch (ex: Exception) { - ToastPopup(errorText, this) + ToastPopup(errorText, this@MainMenuScreen) } } } diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 42f3507318..12c98477b7 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -19,11 +19,14 @@ import com.unciv.ui.audio.MusicMood import com.unciv.ui.utils.* import com.unciv.ui.worldscreen.PlayerReadyScreen import com.unciv.ui.worldscreen.WorldScreen -import com.unciv.logic.multiplayer.OnlineMultiplayer +import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver import com.unciv.ui.audio.Sounds -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.closeExecutors +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter +import com.unciv.ui.multiplayer.LoadDeepLinkScreen +import com.unciv.ui.popup.Popup import java.util.* class UncivGame(parameters: UncivGameParameters) : Game() { @@ -114,7 +117,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { Gdx.graphics.isContinuousRendering = settings.continuousRendering - crashHandlingThread(name = "LoadJSON") { + launchCrashHandling("LoadJSON") { RulesetCache.loadRulesets(printOutput = true) translations.tryReadTranslationForCurrentLanguage() translations.loadPercentageCompleteOfLanguages() @@ -169,13 +172,26 @@ class UncivGame(parameters: UncivGameParameters) : Game() { Gdx.graphics.requestRendering() } - fun tryLoadDeepLinkedGame() { + fun tryLoadDeepLinkedGame() = launchCrashHandling("LoadDeepLinkedGame") { if (deepLinkedMultiplayerGame != null) { + postCrashHandlingRunnable { + setScreen(LoadDeepLinkScreen()) + } try { - val onlineGame = OnlineMultiplayer().tryDownloadGame(deepLinkedMultiplayerGame!!) - loadGame(onlineGame) + val onlineGame = OnlineMultiplayerGameSaver().tryDownloadGame(deepLinkedMultiplayerGame!!) + postCrashHandlingRunnable { + loadGame(onlineGame) + } } catch (ex: Exception) { - setScreen(MainMenuScreen()) + postCrashHandlingRunnable { + val mainMenu = MainMenuScreen() + setScreen(mainMenu) + val popup = Popup(mainMenu) + popup.addGoodSizedLabel("Failed to load multiplayer game: ${ex.message ?: ex::class.simpleName}") + popup.row() + popup.addCloseButton() + popup.open() + } } } } @@ -210,6 +226,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { cancelDiscordEvent?.invoke() Sounds.clearCache() if (::musicController.isInitialized) musicController.gracefulShutdown() // Do allow fade-out + closeExecutors() // Log still running threads (on desktop that should be only this one and "DestroyJavaVM") val numThreads = Thread.activeCount() diff --git a/core/src/com/unciv/logic/GameSaver.kt b/core/src/com/unciv/logic/GameSaver.kt index 01cd907ffc..8d762bc9a5 100644 --- a/core/src/com/unciv/logic/GameSaver.kt +++ b/core/src/com/unciv/logic/GameSaver.kt @@ -4,9 +4,8 @@ import com.badlogic.gdx.Gdx import com.badlogic.gdx.files.FileHandle import com.unciv.UncivGame import com.unciv.json.json -import com.unciv.logic.multiplayer.OnlineMultiplayer import com.unciv.models.metadata.GameSettings -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.saves.Gzip import java.io.File @@ -31,20 +30,20 @@ object GameSaver { //endregion //region Helpers - private fun getSubfolder(multiplayer: Boolean = false) = if (multiplayer) multiplayerFilesFolder else saveFilesFolder + private fun getSavefolder(multiplayer: Boolean = false) = if (multiplayer) multiplayerFilesFolder else saveFilesFolder fun getSave(GameName: String, multiplayer: Boolean = false): FileHandle { - val localFile = Gdx.files.local("${getSubfolder(multiplayer)}/$GameName") + val localFile = Gdx.files.local("${getSavefolder(multiplayer)}/$GameName") if (externalFilesDirForAndroid == "" || !Gdx.files.isExternalStorageAvailable) return localFile - val externalFile = Gdx.files.absolute(externalFilesDirForAndroid + "/${getSubfolder(multiplayer)}/$GameName") + val externalFile = Gdx.files.absolute(externalFilesDirForAndroid + "/${getSavefolder(multiplayer)}/$GameName") if (localFile.exists() && !externalFile.exists()) return localFile return externalFile } fun getSaves(multiplayer: Boolean = false): Sequence { - val localSaves = Gdx.files.local(getSubfolder(multiplayer)).list().asSequence() + val localSaves = Gdx.files.local(getSavefolder(multiplayer)).list().asSequence() if (externalFilesDirForAndroid == "" || !Gdx.files.isExternalStorageAvailable) return localSaves - return localSaves + Gdx.files.absolute(externalFilesDirForAndroid + "/${getSubfolder(multiplayer)}").list().asSequence() + return localSaves + Gdx.files.absolute(externalFilesDirForAndroid + "/${getSavefolder(multiplayer)}").list().asSequence() } fun canLoadFromCustomSaveLocation() = customSaveLocationHelper != null @@ -56,12 +55,21 @@ object GameSaver { //endregion //region Saving - fun saveGame(game: GameInfo, GameName: String, saveCompletionCallback: ((Exception?) -> Unit)? = null) { + fun saveGame(game: GameInfo, GameName: String, saveCompletionCallback: (Exception?) -> Unit = { if (it != null) throw it }): FileHandle { + val file = getSave(GameName) + saveGame(game, file, saveCompletionCallback) + return file + } + + /** + * Only use this with a [FileHandle] obtained by [getSaves]! + */ + fun saveGame(game: GameInfo, file: FileHandle, saveCompletionCallback: (Exception?) -> Unit = { if (it != null) throw it }) { try { - getSave(GameName).writeString(gameInfoToString(game), false) - saveCompletionCallback?.invoke(null) + file.writeString(gameInfoToString(game), false) + saveCompletionCallback(null) } catch (ex: Exception) { - saveCompletionCallback?.invoke(ex) + saveCompletionCallback(ex) } } @@ -71,7 +79,7 @@ object GameSaver { return if (forceZip ?: saveZipped) Gzip.zip(plainJson) else plainJson } - /** Returns gzipped serialization of preview [game] - only called from [OnlineMultiplayer] */ + /** Returns gzipped serialization of preview [game] - only called from [OnlineMultiplayerGameSaver] */ fun gameInfoToString(game: GameInfoPreview): String { return Gzip.zip(json().toJson(game)) } @@ -79,12 +87,21 @@ object GameSaver { /** * Overload of function saveGame to save a GameInfoPreview in the MultiplayerGames folder */ - fun saveGame(game: GameInfoPreview, GameName: String, saveCompletionCallback: ((Exception?) -> Unit)? = null) { + fun saveGame(game: GameInfoPreview, GameName: String, saveCompletionCallback: (Exception?) -> Unit = { if (it != null) throw it }): FileHandle { + val file = getSave(GameName, true) + saveGame(game, file, saveCompletionCallback) + return file + } + + /** + * Only use this with a [FileHandle] obtained by [getSaves]! + */ + fun saveGame(game: GameInfoPreview, file: FileHandle, saveCompletionCallback: (Exception?) -> Unit = { if (it != null) throw it }) { try { - json().toJson(game, getSave(GameName, true)) - saveCompletionCallback?.invoke(null) + json().toJson(game, file) + saveCompletionCallback(null) } catch (ex: Exception) { - saveCompletionCallback?.invoke(ex) + saveCompletionCallback(ex) } } @@ -121,7 +138,7 @@ object GameSaver { } } - /** Parses [gameData] as gzipped serialization of a [GameInfoPreview] - only called from [OnlineMultiplayer] */ + /** Parses [gameData] as gzipped serialization of a [GameInfoPreview] - only called from [OnlineMultiplayerGameSaver] */ fun gameInfoPreviewFromString(gameData: String): GameInfoPreview { return json().fromJson(GameInfoPreview::class.java, Gzip.unzip(gameData)) } @@ -184,7 +201,7 @@ object GameSaver { fun autoSaveUnCloned(gameInfo: GameInfo, postRunnable: () -> Unit = {}) { // This is used when returning from WorldScreen to MainMenuScreen - no clone since UI access to it should be gone - crashHandlingThread(name = autoSaveFileName) { + launchCrashHandling(autoSaveFileName, runAsDaemon = false) { autoSaveSingleThreaded(gameInfo) // do this on main thread postCrashHandlingRunnable ( postRunnable ) diff --git a/core/src/com/unciv/logic/multiplayer/Multiplayer.kt b/core/src/com/unciv/logic/multiplayer/Multiplayer.kt deleted file mode 100644 index 81f3bf2073..0000000000 --- a/core/src/com/unciv/logic/multiplayer/Multiplayer.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.unciv.logic.multiplayer - -import com.badlogic.gdx.Net -import com.unciv.Constants -import com.unciv.UncivGame -import com.unciv.logic.GameInfo -import com.unciv.logic.GameInfoPreview -import com.unciv.logic.GameSaver -import java.io.FileNotFoundException -import java.util.* - -interface IFileStorage { - /** - * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time - * @throws FileStorageConflictException if the file already exists and [overwrite] is false - */ - fun saveFileData(fileName: String, data: String, overwrite: Boolean) - /** - * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time - * @throws FileNotFoundException if the file can't be found - */ - fun loadFileData(fileName: String): String - /** - * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time - * @throws FileNotFoundException if the file can't be found - */ - fun getFileMetaData(fileName: String): IFileMetaData - /** - * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time - * @throws FileNotFoundException if the file can't be found - */ - fun deleteFile(fileName: String) -} - -interface IFileMetaData { - fun getLastModified(): Date? -} - - - -class UncivServerFileStorage(val serverUrl:String):IFileStorage { - override fun saveFileData(fileName: String, data: String, overwrite: Boolean) { - SimpleHttp.sendRequest(Net.HttpMethods.PUT, "$serverUrl/files/$fileName", data){ - success: Boolean, result: String -> - if (!success) { - println(result) - throw java.lang.Exception(result) - } - } - } - - override fun loadFileData(fileName: String): String { - var fileData = "" - SimpleHttp.sendGetRequest("$serverUrl/files/$fileName"){ - success: Boolean, result: String -> - if (!success) { - println(result) - throw java.lang.Exception(result) - } - else fileData = result - } - return fileData - } - - override fun getFileMetaData(fileName: String): IFileMetaData { - TODO("Not yet implemented") - } - - override fun deleteFile(fileName: String) { - SimpleHttp.sendRequest(Net.HttpMethods.DELETE, "$serverUrl/files/$fileName", ""){ - success: Boolean, result: String -> - if (!success) throw java.lang.Exception(result) - } - } - -} - -class FileStorageConflictException: Exception() -class FileStorageRateLimitReached(val limitRemainingSeconds: Int): Exception() - -/** - * Allows access to games stored on a server for multiplayer purposes. - * Defaults to using UncivGame.Current.settings.multiplayerServer if fileStorageIdentifier is not given. - * - * @param fileStorageIdentifier must be given if UncivGame.Current might not be initialized - * @see IFileStorage - * @see UncivGame.Current.settings.multiplayerServer - */ -class OnlineMultiplayer(var fileStorageIdentifier: String? = null) { - val fileStorage: IFileStorage - init { - if (fileStorageIdentifier == null) - fileStorageIdentifier = UncivGame.Current.settings.multiplayerServer - fileStorage = if (fileStorageIdentifier == Constants.dropboxMultiplayerServer) - DropBox - else UncivServerFileStorage(fileStorageIdentifier!!) - } - - fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) { - // We upload the gamePreview before we upload the game as this - // seems to be necessary for the kick functionality - if (withPreview) { - tryUploadGamePreview(gameInfo.asPreview()) - } - - val zippedGameInfo = GameSaver.gameInfoToString(gameInfo, forceZip = true) - fileStorage.saveFileData(gameInfo.gameId, zippedGameInfo, true) - } - - /** - * Used to upload only the preview of a game. If the preview is uploaded together with (before/after) - * the gameInfo, it is recommended to use tryUploadGame(gameInfo, withPreview = true) - * @see tryUploadGame - * @see GameInfo.asPreview - */ - fun tryUploadGamePreview(gameInfo: GameInfoPreview) { - val zippedGameInfo = GameSaver.gameInfoToString(gameInfo) - fileStorage.saveFileData("${gameInfo.gameId}_Preview", zippedGameInfo, true) - } - - fun tryDownloadGame(gameId: String): GameInfo { - val zippedGameInfo = fileStorage.loadFileData(gameId) - return GameSaver.gameInfoFromString(zippedGameInfo) - } - - fun tryDownloadGamePreview(gameId: String): GameInfoPreview { - val zippedGameInfo = fileStorage.loadFileData("${gameId}_Preview") - return GameSaver.gameInfoPreviewFromString(zippedGameInfo) - } -} \ No newline at end of file diff --git a/core/src/com/unciv/logic/multiplayer/DropBox.kt b/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt similarity index 96% rename from core/src/com/unciv/logic/multiplayer/DropBox.kt rename to core/src/com/unciv/logic/multiplayer/storage/DropBox.kt index af6f2f880c..fe1ce5517a 100644 --- a/core/src/com/unciv/logic/multiplayer/DropBox.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/DropBox.kt @@ -1,4 +1,4 @@ -package com.unciv.logic.multiplayer +package com.unciv.logic.multiplayer.storage import com.unciv.json.json import com.unciv.ui.utils.UncivDateFormat.parseDate @@ -11,7 +11,7 @@ import kotlin.collections.ArrayList import kotlin.concurrent.timer -object DropBox: IFileStorage { +object DropBox: FileStorage { private var remainingRateLimitSeconds = 0 private var rateLimitTimer: Timer? = null @@ -76,7 +76,7 @@ object DropBox: IFileStorage { ) } - override fun getFileMetaData(fileName: String): IFileMetaData { + override fun getFileMetaData(fileName: String): FileMetaData { val stream = dropboxApi( url="https://api.dropboxapi.com/2/files/get_metadata", data="{\"path\":\"${getLocalGameLocation(fileName)}\"}", @@ -124,8 +124,8 @@ object DropBox: IFileStorage { throw FileStorageRateLimitReached(remainingRateLimitSeconds) } - fun getFolderList(folder: String): ArrayList { - val folderList = ArrayList() + fun getFolderList(folder: String): ArrayList { + val folderList = ArrayList() // The DropBox API returns only partial file listings from one request. list_folder and // list_folder/continue return similar responses, but list_folder/continue requires a cursor // instead of the path. @@ -168,7 +168,7 @@ object DropBox: IFileStorage { } @Suppress("PropertyName") - private class MetaData: IFileMetaData { + private class MetaData: FileMetaData { var name = "" private var server_modified = "" diff --git a/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt b/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt new file mode 100644 index 0000000000..c6e8c13515 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/storage/FileStorage.kt @@ -0,0 +1,34 @@ +package com.unciv.logic.multiplayer.storage + +import java.io.FileNotFoundException +import java.util.* + +class FileStorageConflictException : Exception() +class FileStorageRateLimitReached(val limitRemainingSeconds: Int) : Exception() + +interface FileMetaData { + fun getLastModified(): Date? +} + +interface FileStorage { + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws FileStorageConflictException if the file already exists and [overwrite] is false + */ + fun saveFileData(fileName: String, data: String, overwrite: Boolean) + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws FileNotFoundException if the file can't be found + */ + fun loadFileData(fileName: String): String + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws FileNotFoundException if the file can't be found + */ + fun getFileMetaData(fileName: String): FileMetaData + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws FileNotFoundException if the file can't be found + */ + fun deleteFile(fileName: String) +} diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerGameSaver.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerGameSaver.kt new file mode 100644 index 0000000000..f5219bc97d --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerGameSaver.kt @@ -0,0 +1,58 @@ +package com.unciv.logic.multiplayer.storage + +import com.unciv.Constants +import com.unciv.UncivGame +import com.unciv.logic.GameInfo +import com.unciv.logic.GameInfoPreview +import com.unciv.logic.GameSaver + +/** + * Allows access to games stored on a server for multiplayer purposes. + * Defaults to using UncivGame.Current.settings.multiplayerServer if fileStorageIdentifier is not given. + * + * @param fileStorageIdentifier must be given if UncivGame.Current might not be initialized + * @see FileStorage + * @see UncivGame.Current.settings.multiplayerServer + */ +@Suppress("RedundantSuspendModifier") // Methods can take a long time, so force users to use them in a coroutine to not get ANRs on Android +class OnlineMultiplayerGameSaver( + private var fileStorageIdentifier: String? = null +) { + fun fileStorage(): FileStorage { + val identifier = if (fileStorageIdentifier == null) UncivGame.Current.settings.multiplayerServer else fileStorageIdentifier + + return if (identifier == Constants.dropboxMultiplayerServer) DropBox else UncivServerFileStorage(identifier!!) + } + + suspend fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) { + // We upload the gamePreview before we upload the game as this + // seems to be necessary for the kick functionality + if (withPreview) { + tryUploadGamePreview(gameInfo.asPreview()) + } + + val zippedGameInfo = GameSaver.gameInfoToString(gameInfo, forceZip = true) + fileStorage().saveFileData(gameInfo.gameId, zippedGameInfo, true) + } + + /** + * Used to upload only the preview of a game. If the preview is uploaded together with (before/after) + * the gameInfo, it is recommended to use tryUploadGame(gameInfo, withPreview = true) + * @see tryUploadGame + * @see GameInfo.asPreview + */ + suspend fun tryUploadGamePreview(gameInfo: GameInfoPreview) { + val zippedGameInfo = GameSaver.gameInfoToString(gameInfo) + fileStorage().saveFileData("${gameInfo.gameId}_Preview", zippedGameInfo, true) + } + + suspend fun tryDownloadGame(gameId: String): GameInfo { + val zippedGameInfo = fileStorage().loadFileData(gameId) + return GameSaver.gameInfoFromString(zippedGameInfo) + } + + suspend fun tryDownloadGamePreview(gameId: String): GameInfoPreview { + val zippedGameInfo = fileStorage().loadFileData("${gameId}_Preview") + return GameSaver.gameInfoPreviewFromString(zippedGameInfo) + } +} \ No newline at end of file diff --git a/core/src/com/unciv/logic/multiplayer/ServerMutex.kt b/core/src/com/unciv/logic/multiplayer/storage/ServerMutex.kt similarity index 89% rename from core/src/com/unciv/logic/multiplayer/ServerMutex.kt rename to core/src/com/unciv/logic/multiplayer/storage/ServerMutex.kt index 021d19ed9b..cb7426a6c0 100644 --- a/core/src/com/unciv/logic/multiplayer/ServerMutex.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/ServerMutex.kt @@ -1,4 +1,4 @@ -package com.unciv.logic.multiplayer +package com.unciv.logic.multiplayer.storage import com.unciv.json.json import com.unciv.logic.GameInfo @@ -49,7 +49,7 @@ class ServerMutex(val gameInfo: GameInfoPreview) { // We have to check if the lock file already exists before we try to upload a new // lock file to not overuse the dropbox file upload limit else it will return an error try { - val metaData = OnlineMultiplayer().fileStorage.getFileMetaData(fileName) + val metaData = OnlineMultiplayerGameSaver().fileStorage().getFileMetaData(fileName) val date = metaData.getLastModified() // 30 seconds should be more than sufficient for everything lock related @@ -57,7 +57,7 @@ class ServerMutex(val gameInfo: GameInfoPreview) { if (date != null && System.currentTimeMillis() - date.time < 30000) { return locked } else { - OnlineMultiplayer().fileStorage.deleteFile(fileName) + OnlineMultiplayerGameSaver().fileStorage().deleteFile(fileName) } } catch (ex: FileNotFoundException) { // Catching this exception means no lock file is present @@ -65,7 +65,7 @@ class ServerMutex(val gameInfo: GameInfoPreview) { } try { - OnlineMultiplayer().fileStorage.saveFileData(fileName, Gzip.zip(json().toJson(LockFile())), false) + OnlineMultiplayerGameSaver().fileStorage().saveFileData(fileName, Gzip.zip(json().toJson(LockFile())), false) } catch (ex: FileStorageConflictException) { return locked } @@ -116,7 +116,7 @@ class ServerMutex(val gameInfo: GameInfoPreview) { if (!locked) return - OnlineMultiplayer().fileStorage.deleteFile("${gameInfo.gameId}_Lock") + OnlineMultiplayerGameSaver().fileStorage().deleteFile("${gameInfo.gameId}_Lock") locked = false } diff --git a/core/src/com/unciv/logic/multiplayer/SimpleHttp.kt b/core/src/com/unciv/logic/multiplayer/storage/SimpleHttp.kt similarity index 85% rename from core/src/com/unciv/logic/multiplayer/SimpleHttp.kt rename to core/src/com/unciv/logic/multiplayer/storage/SimpleHttp.kt index 48b6b32b5f..11ff73e1ce 100644 --- a/core/src/com/unciv/logic/multiplayer/SimpleHttp.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/SimpleHttp.kt @@ -1,4 +1,4 @@ -package com.unciv.logic.multiplayer +package com.unciv.logic.multiplayer.storage import com.badlogic.gdx.Net import com.unciv.UncivGame @@ -9,11 +9,11 @@ import java.net.* import java.nio.charset.Charset object SimpleHttp { - fun sendGetRequest(url: String, action: (success: Boolean, result: String)->Unit) { + fun sendGetRequest(url: String, action: (success: Boolean, result: String, code: Int?)->Unit) { sendRequest(Net.HttpMethods.GET, url, "", action) } - fun sendRequest(method: String, url: String, content: String, action: (success: Boolean, result: String)->Unit) { + fun sendRequest(method: String, url: String, content: String, action: (success: Boolean, result: String, code: Int?)->Unit) { var uri = URI(url) if (uri.host == null) uri = URI("http://$url") @@ -21,7 +21,7 @@ object SimpleHttp { try { urlObj = uri.toURL() } catch (t:Throwable){ - action(false, "Bad URL") + action(false, "Bad URL", null) return } @@ -43,14 +43,14 @@ object SimpleHttp { } val text = BufferedReader(InputStreamReader(inputStream)).readText() - action(true, text) + action(true, text, responseCode) } catch (t: Throwable) { println(t.message) val errorMessageToReturn = if (errorStream != null) BufferedReader(InputStreamReader(errorStream)).readText() else t.message!! println(errorMessageToReturn) - action(false, errorMessageToReturn) + action(false, errorMessageToReturn, if (errorStream != null) responseCode else null) } } } diff --git a/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt b/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt new file mode 100644 index 0000000000..b77cf01f86 --- /dev/null +++ b/core/src/com/unciv/logic/multiplayer/storage/UncivServerFileStorage.kt @@ -0,0 +1,51 @@ +package com.unciv.logic.multiplayer.storage + +import com.badlogic.gdx.Net +import java.io.FileNotFoundException +import java.lang.Exception + +class UncivServerFileStorage(val serverUrl:String): FileStorage { + override fun saveFileData(fileName: String, data: String, overwrite: Boolean) { + SimpleHttp.sendRequest(Net.HttpMethods.PUT, "$serverUrl/files/$fileName", data) { + success, result, code -> + if (!success) { + println(result) + throw Exception(result) + } + } + } + + override fun loadFileData(fileName: String): String { + var fileData = "" + SimpleHttp.sendGetRequest("$serverUrl/files/$fileName"){ + success, result, code -> + if (!success) { + println(result) + when (code) { + 404 -> throw FileNotFoundException(result) + else -> throw Exception(result) + } + + } + else fileData = result + } + return fileData + } + + override fun getFileMetaData(fileName: String): FileMetaData { + TODO("Not yet implemented") + } + + override fun deleteFile(fileName: String) { + SimpleHttp.sendRequest(Net.HttpMethods.DELETE, "$serverUrl/files/$fileName", "") { + success, result, code -> + if (!success) { + when (code) { + 404 -> throw FileNotFoundException(result) + else -> throw Exception(result) + } + } + } + } + +} diff --git a/core/src/com/unciv/models/simulation/Simulation.kt b/core/src/com/unciv/models/simulation/Simulation.kt index e86892f63f..22ff806b0a 100644 --- a/core/src/com/unciv/models/simulation/Simulation.kt +++ b/core/src/com/unciv/models/simulation/Simulation.kt @@ -5,7 +5,9 @@ import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.GameStarter import com.unciv.models.metadata.GameSetupInfo -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking import kotlin.time.Duration import kotlin.math.max import kotlin.time.ExperimentalTime @@ -40,12 +42,12 @@ class Simulation( } } - fun start() { + fun start() = runBlocking { startTime = System.currentTimeMillis() - val threads: ArrayList = ArrayList() + val jobs: ArrayList = ArrayList() for (threadId in 1..threadsNumber) { - threads.add(crashHandlingThread { + jobs.add(launchCrashHandling("simulation-${threadId}") { for (i in 1..simulationsPerThread) { val gameInfo = GameStarter.startNewGame(GameSetupInfo(newGameInfo)) gameInfo.simulateMaxTurns = maxTurns @@ -66,8 +68,8 @@ class Simulation( } }) } - // wait for all threads to finish - for (thread in threads) thread.join() + // wait for all to finish + for (job in jobs) job.join() endTime = System.currentTimeMillis() } diff --git a/core/src/com/unciv/ui/audio/MusicController.kt b/core/src/com/unciv/ui/audio/MusicController.kt index c622e83675..74aac3cca1 100644 --- a/core/src/com/unciv/ui/audio/MusicController.kt +++ b/core/src/com/unciv/ui/audio/MusicController.kt @@ -6,7 +6,7 @@ import com.badlogic.gdx.audio.Music import com.badlogic.gdx.files.FileHandle import com.unciv.UncivGame import com.unciv.models.metadata.GameSettings -import com.unciv.logic.multiplayer.DropBox +import com.unciv.logic.multiplayer.storage.DropBox import java.util.* import kotlin.concurrent.thread import kotlin.concurrent.timer diff --git a/core/src/com/unciv/ui/audio/Sounds.kt b/core/src/com/unciv/ui/audio/Sounds.kt index bf9fa732ff..8598bc0fba 100644 --- a/core/src/com/unciv/ui/audio/Sounds.kt +++ b/core/src/com/unciv/ui/audio/Sounds.kt @@ -6,7 +6,8 @@ import com.badlogic.gdx.audio.Sound import com.badlogic.gdx.files.FileHandle import com.unciv.UncivGame import com.unciv.models.UncivSound -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling +import kotlinx.coroutines.delay import java.io.File /* @@ -164,10 +165,10 @@ object Sounds { val initialDelay = if (isFresh && Gdx.app.type == Application.ApplicationType.Android) 40 else 0 if (initialDelay > 0 || resource.play(volume) == -1L) { - crashHandlingThread(name = "DelayedSound") { - Thread.sleep(initialDelay.toLong()) + launchCrashHandling("DelayedSound") { + delay(initialDelay.toLong()) while (resource.play(volume) == -1L) { - Thread.sleep(20L) + delay(20L) } } } diff --git a/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt b/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt index 38d8cb6227..b7b2461431 100644 --- a/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt +++ b/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt @@ -17,7 +17,7 @@ import com.unciv.models.ruleset.unit.BaseUnit import com.unciv.models.stats.Stat import com.unciv.models.translations.tr import com.unciv.ui.audio.Sounds -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.popup.Popup @@ -207,7 +207,7 @@ class CityConstructionsTable(private val cityScreen: CityScreen) { availableConstructionsTable.add("Loading...".toLabel()).pad(10f) } - crashHandlingThread(name = "Construction info gathering - ${cityScreen.city.name}") { + launchCrashHandling("Construction info gathering - ${cityScreen.city.name}") { // Since this can be a heavy operation and leads to many ANRs on older phones we put the metadata-gathering in another thread. val constructionButtonDTOList = getConstructionButtonDTOs() postCrashHandlingRunnable { diff --git a/core/src/com/unciv/ui/crashhandling/CrashHandlingThread.kt b/core/src/com/unciv/ui/crashhandling/CrashHandlingThread.kt index 6af33983d0..0f78a78507 100644 --- a/core/src/com/unciv/ui/crashhandling/CrashHandlingThread.kt +++ b/core/src/com/unciv/ui/crashhandling/CrashHandlingThread.kt @@ -2,10 +2,45 @@ package com.unciv.ui.crashhandling import com.badlogic.gdx.Gdx import com.unciv.ui.utils.wrapCrashHandlingUnit +import kotlinx.coroutines.* +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory import kotlin.concurrent.thread +private val DAEMON_EXECUTOR = Executors.newCachedThreadPool(object : ThreadFactory { + var n = 0 + override fun newThread(r: java.lang.Runnable): Thread = + crashHandlingThread(name = "crash-handling-daemon-${n++}", start = false, isDaemon = true, block = r::run) +}).asCoroutineDispatcher() +/** + * Coroutine Scope that runs coroutines in separate daemon threads. + * + * Brings the main game loop to a [com.unciv.CrashScreen] if an exception happens. + */ +val CRASH_HANDLING_DAEMON_SCOPE = CoroutineScope(DAEMON_EXECUTOR) + +private val EXECUTOR = Executors.newCachedThreadPool(object : ThreadFactory { + var n = 0 + override fun newThread(r: java.lang.Runnable): Thread = + crashHandlingThread(name = "crash-handling-${n++}", start = false, isDaemon = false, block = r::run) +}).asCoroutineDispatcher() +/** + * Coroutine Scope that runs coroutines in separate threads that are not started as daemons. + * + * Brings the main game loop to a [com.unciv.CrashScreen] if an exception happens. + */ +val CRASH_HANDLING_SCOPE = CoroutineScope(EXECUTOR) + +/** + * Must be called only in [com.unciv.UncivGame.dispose] to not have any threads running that prevent JVM shutdown. + */ +fun closeExecutors() { + EXECUTOR.close() + DAEMON_EXECUTOR.close() +} + /** Wrapped version of [kotlin.concurrent.thread], that brings the main game loop to a [com.unciv.CrashScreen] if an exception happens. */ -fun crashHandlingThread( +private fun crashHandlingThread( start: Boolean = true, isDaemon: Boolean = false, contextClassLoader: ClassLoader? = null, @@ -25,3 +60,24 @@ fun crashHandlingThread( fun postCrashHandlingRunnable(runnable: () -> Unit) { Gdx.app.postRunnable(runnable.wrapCrashHandlingUnit()) } + +/** + * [launch]es a new coroutine that brings the game loop to a [com.unciv.CrashScreen] if an exception occurs. + * @see crashHandlingThread + */ +fun launchCrashHandling(name: String, runAsDaemon: Boolean = true, + flowBlock: suspend CoroutineScope.() -> Unit): Job { + return getCoroutineContext(runAsDaemon).launch(CoroutineName(name)) { flowBlock(this) } +} +/** + * Uses [async] to return a result from a new coroutine that brings the game loop to a [com.unciv.CrashScreen] if an exception occurs. + * @see crashHandlingThread + */ +fun asyncCrashHandling(name: String, runAsDaemon: Boolean = true, + flowBlock: suspend CoroutineScope.() -> T): Deferred { + return getCoroutineContext(runAsDaemon).async(CoroutineName(name)) { flowBlock(this) } +} + +private fun getCoroutineContext(runAsDaemon: Boolean): CoroutineScope { + return if (runAsDaemon) CRASH_HANDLING_DAEMON_SCOPE else CRASH_HANDLING_SCOPE +} \ No newline at end of file diff --git a/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt b/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt index 3bfbb96bcf..dce46eeec2 100644 --- a/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt +++ b/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt @@ -5,12 +5,12 @@ import com.badlogic.gdx.scenes.scene2d.ui.TextField import com.unciv.logic.GameInfoPreview import com.unciv.logic.GameSaver import com.unciv.logic.civilization.PlayerType -import com.unciv.logic.multiplayer.FileStorageRateLimitReached +import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached +import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver import com.unciv.models.translations.tr import com.unciv.ui.pickerscreens.PickerScreen import com.unciv.ui.utils.* -import com.unciv.logic.multiplayer.OnlineMultiplayer -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.popup.Popup import com.unciv.ui.popup.YesNoPopup @@ -83,10 +83,10 @@ class EditMultiplayerGameInfoScreen(val gameInfo: GameInfoPreview?, gameName: St popup.addGoodSizedLabel("Working...").row() popup.open() - crashHandlingThread { + launchCrashHandling("Resign", runAsDaemon = false) { try { //download to work with newest game state - val gameInfo = OnlineMultiplayer().tryDownloadGame(gameId) + val gameInfo = OnlineMultiplayerGameSaver().tryDownloadGame(gameId) val playerCiv = gameInfo.currentPlayerCiv //only give up if it's the users turn @@ -106,9 +106,9 @@ class EditMultiplayerGameInfoScreen(val gameInfo: GameInfoPreview?, gameName: St } //save game so multiplayer list stays up to date but do not override multiplayer settings - val updatedSave = this.gameInfo!!.updateCurrentTurn(gameInfo) + val updatedSave = this@EditMultiplayerGameInfoScreen.gameInfo!!.updateCurrentTurn(gameInfo) GameSaver.saveGame(updatedSave, gameName) - OnlineMultiplayer().tryUploadGame(gameInfo, withPreview = true) + OnlineMultiplayerGameSaver().tryUploadGame(gameInfo, withPreview = true) postCrashHandlingRunnable { popup.close() diff --git a/core/src/com/unciv/ui/multiplayer/LoadDeepLinkScreen.kt b/core/src/com/unciv/ui/multiplayer/LoadDeepLinkScreen.kt new file mode 100644 index 0000000000..4ace26c014 --- /dev/null +++ b/core/src/com/unciv/ui/multiplayer/LoadDeepLinkScreen.kt @@ -0,0 +1,14 @@ +package com.unciv.ui.multiplayer + +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.unciv.ui.utils.BaseScreen +import com.unciv.ui.utils.center +import com.unciv.ui.utils.toLabel + +class LoadDeepLinkScreen : BaseScreen() { + init { + val loadingLabel = "Loading...".toLabel() + stage.addActor(loadingLabel) + loadingLabel.center(stage) + } +} \ No newline at end of file diff --git a/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt b/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt index b8b4342594..072c41edfd 100644 --- a/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt +++ b/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt @@ -4,12 +4,12 @@ import com.badlogic.gdx.Gdx import com.badlogic.gdx.files.FileHandle import com.badlogic.gdx.scenes.scene2d.ui.* import com.unciv.logic.* -import com.unciv.logic.multiplayer.FileStorageRateLimitReached +import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached +import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver import com.unciv.models.translations.tr import com.unciv.ui.pickerscreens.PickerScreen import com.unciv.ui.utils.* -import com.unciv.logic.multiplayer.OnlineMultiplayer -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.popup.Popup @@ -146,11 +146,10 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { addGameButton.setText("Working...".tr()) addGameButton.disable() - crashHandlingThread(name = "MultiplayerDownload") { + + launchCrashHandling("MultiplayerDownload", runAsDaemon = false) { try { - // The tryDownload can take more than 500ms. Therefore, to avoid ANRs, - // we need to run it in a different thread. - val gamePreview = OnlineMultiplayer().tryDownloadGamePreview(gameId.trim()) + val gamePreview = OnlineMultiplayerGameSaver().tryDownloadGamePreview(gameId.trim()) if (gameName == "") GameSaver.saveGame(gamePreview, gamePreview.gameId) else @@ -160,7 +159,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { } catch (ex: FileNotFoundException) { // Game is so old that a preview could not be found on dropbox lets try the real gameInfo instead try { - val gamePreview = OnlineMultiplayer().tryDownloadGame(gameId.trim()).asPreview() + val gamePreview = OnlineMultiplayerGameSaver().tryDownloadGame(gameId.trim()).asPreview() if (gameName == "") GameSaver.saveGame(gamePreview, gamePreview.gameId) else @@ -172,13 +171,13 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { popup.reuseWith("Could not download game!", true) } } - } catch (ex: FileStorageRateLimitReached) { - postCrashHandlingRunnable { - popup.reuseWith("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds", true) - } } catch (ex: Exception) { postCrashHandlingRunnable { - popup.reuseWith("Could not download game!", true) + val message = when (ex) { + is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds" + else -> "Could not download game!" + } + popup.reuseWith(message, true) } } postCrashHandlingRunnable { @@ -194,18 +193,18 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { loadingGamePopup.add("Loading latest game state...".tr()) loadingGamePopup.open() - crashHandlingThread(name = "JoinMultiplayerGame") { + launchCrashHandling("JoinMultiplayerGame") { try { val gameId = multiplayerGames[selectedGameFile]!!.gameId - val gameInfo = OnlineMultiplayer().tryDownloadGame(gameId) + val gameInfo = OnlineMultiplayerGameSaver().tryDownloadGame(gameId) postCrashHandlingRunnable { game.loadGame(gameInfo) } - } catch (ex: FileStorageRateLimitReached) { - postCrashHandlingRunnable { - loadingGamePopup.reuseWith("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds", true) - } } catch (ex: Exception) { + val message = when (ex) { + is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds" + else -> "Could not download game!" + } postCrashHandlingRunnable { - loadingGamePopup.reuseWith("Could not download game!", true) + loadingGamePopup.reuseWith(message, true) } } } @@ -280,7 +279,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { continue } - crashHandlingThread(name = "loadGameFile") { + launchCrashHandling("loadGameFile") { try { val game = gameSaver.loadGamePreviewFromFile(gameSaveFile) @@ -301,7 +300,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { } catch (usx: UncivShowableException) { //Gets thrown when mods are not installed postCrashHandlingRunnable { - val popup = Popup(this) + val popup = Popup(this@MultiplayerScreen) popup.addGoodSizedLabel(usx.message!! + " in ${gameSaveFile.name()}").row() popup.addCloseButton() popup.open(true) @@ -311,7 +310,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { } } catch (ex: Exception) { postCrashHandlingRunnable { - ToastPopup("Could not refresh!", this) + ToastPopup("Could not refresh!", this@MultiplayerScreen) turnIndicator.clear() turnIndicator.add(ImageGetter.getImage("StatIcons/Malcontent")).size(50f) } @@ -330,12 +329,11 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { refreshButton.setText("Working...".tr()) refreshButton.disable() - //One thread for all downloads - crashHandlingThread(name = "multiplayerGameDownload") { + launchCrashHandling("multiplayerGameDownload") { for ((fileHandle, gameInfo) in multiplayerGames) { try { // Update game without overriding multiplayer settings - val game = gameInfo.updateCurrentTurn(OnlineMultiplayer().tryDownloadGamePreview(gameInfo.gameId)) + val game = gameInfo.updateCurrentTurn(OnlineMultiplayerGameSaver().tryDownloadGamePreview(gameInfo.gameId)) GameSaver.saveGame(game, fileHandle.name()) multiplayerGames[fileHandle] = game @@ -343,25 +341,25 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { // Game is so old that a preview could not be found on dropbox lets try the real gameInfo instead try { // Update game without overriding multiplayer settings - val game = gameInfo.updateCurrentTurn(OnlineMultiplayer().tryDownloadGame(gameInfo.gameId)) + val game = gameInfo.updateCurrentTurn(OnlineMultiplayerGameSaver().tryDownloadGame(gameInfo.gameId)) GameSaver.saveGame(game, fileHandle.name()) multiplayerGames[fileHandle] = game } catch (ex: Exception) { postCrashHandlingRunnable { - ToastPopup("Could not download game!" + " ${fileHandle.name()}", this) + ToastPopup("Could not download game!" + " ${fileHandle.name()}", this@MultiplayerScreen) } } } catch (ex: FileStorageRateLimitReached) { postCrashHandlingRunnable { - ToastPopup("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds", this) + ToastPopup("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds", this@MultiplayerScreen) } break // No need to keep trying if rate limit is reached } catch (ex: Exception) { //skipping one is not fatal //Trying to use as many prev. used strings as possible postCrashHandlingRunnable { - ToastPopup("Could not download game!" + " ${fileHandle.name()}", this) + ToastPopup("Could not download game!" + " ${fileHandle.name()}", this@MultiplayerScreen) } } } diff --git a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt index 7326bcf7ea..6a529220ef 100644 --- a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt @@ -11,12 +11,12 @@ import com.unciv.UncivGame import com.unciv.logic.* import com.unciv.logic.civilization.PlayerType import com.unciv.logic.map.MapType -import com.unciv.logic.multiplayer.FileStorageRateLimitReached -import com.unciv.logic.multiplayer.OnlineMultiplayer +import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached +import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.ruleset.RulesetCache import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.pickerscreens.PickerScreen @@ -160,9 +160,9 @@ class NewGameScreen( rightSideButton.disable() rightSideButton.setText("Working...".tr()) - crashHandlingThread(name = "NewGame") { - // Creating a new game can take a while and we don't want ANRs - newGameThread() + // Creating a new game can take a while and we don't want ANRs + launchCrashHandling("NewGame", runAsDaemon = false) { + startNewGame() } } } @@ -226,7 +226,7 @@ class NewGameScreen( } } - private fun newGameThread() { + suspend private fun startNewGame() { val popup = Popup(this) postCrashHandlingRunnable { popup.addGoodSizedLabel("Working...").row() @@ -255,7 +255,7 @@ class NewGameScreen( if (gameSetupInfo.gameParameters.isOnlineMultiplayer) { newGame.isUpToDate = true // So we don't try to download it from dropbox the second after we upload it - the file is not yet ready for loading! try { - OnlineMultiplayer().tryUploadGame(newGame, withPreview = true) + OnlineMultiplayerGameSaver().tryUploadGame(newGame, withPreview = true) GameSaver.autoSave(newGame) diff --git a/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt b/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt index 30b2c0ecd5..82d405a019 100644 --- a/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt +++ b/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt @@ -13,7 +13,7 @@ import com.unciv.models.ruleset.ModOptions import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.utils.* @@ -23,6 +23,8 @@ import com.unciv.ui.popup.ToastPopup import com.unciv.ui.popup.YesNoPopup import com.unciv.ui.utils.UncivDateFormat.formatDate import com.unciv.ui.utils.UncivDateFormat.parseDate +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive import java.util.* import kotlin.collections.HashMap import kotlin.math.max @@ -67,11 +69,11 @@ class ModManagementScreen( private var onlineScrollCurrentY = -1f // cleanup - background processing needs to be stopped on exit and memory freed - private var runningSearchThread: Thread? = null + private var runningSearchJob: Job? = null private var stopBackgroundTasks = false override fun dispose() { // make sure the worker threads will not continue trying their time-intensive job - runningSearchThread?.interrupt() + runningSearchJob?.cancel() stopBackgroundTasks = true super.dispose() } @@ -189,20 +191,24 @@ class ModManagementScreen( * calls itself for the next page of search results */ private fun tryDownloadPage(pageNum: Int) { - runningSearchThread = crashHandlingThread(name="GitHubSearch") { + runningSearchJob = launchCrashHandling("GitHubSearch") { val repoSearch: Github.RepoSearch try { repoSearch = Github.tryGetGithubReposWithTopic(amountPerPage, pageNum)!! } catch (ex: Exception) { postCrashHandlingRunnable { - ToastPopup("Could not download mod list", this) + ToastPopup("Could not download mod list", this@ModManagementScreen) } - runningSearchThread = null - return@crashHandlingThread + runningSearchJob = null + return@launchCrashHandling + } + + if (!isActive) { + return@launchCrashHandling } postCrashHandlingRunnable { addModInfoFromRepoSearch(repoSearch, pageNum) } - runningSearchThread = null + runningSearchJob = null } } @@ -389,14 +395,14 @@ class ModManagementScreen( /** Download and install a mod in the background, called both from the right-bottom button and the URL entry popup */ private fun downloadMod(repo: Github.Repo, postAction: () -> Unit = {}) { - crashHandlingThread(name="DownloadMod") { // to avoid ANRs - we've learnt our lesson from previous download-related actions + launchCrashHandling("DownloadMod") { // to avoid ANRs - we've learnt our lesson from previous download-related actions try { val modFolder = Github.downloadAndExtract(repo.html_url, repo.default_branch, Gdx.files.local("mods")) ?: throw Exception() // downloadAndExtract returns null for 404 errors and the like -> display something! Github.rewriteModOptions(repo, modFolder) postCrashHandlingRunnable { - ToastPopup("[${repo.name}] Downloaded!", this) + ToastPopup("[${repo.name}] Downloaded!", this@ModManagementScreen) RulesetCache.loadRulesets() RulesetCache[repo.name]?.let { installedModInfo[repo.name] = ModUIData(it) @@ -408,7 +414,7 @@ class ModManagementScreen( } } catch (ex: Exception) { postCrashHandlingRunnable { - ToastPopup("Could not download [${repo.name}]", this) + ToastPopup("Could not download [${repo.name}]", this@ModManagementScreen) postAction() } } @@ -538,7 +544,7 @@ class ModManagementScreen( } internal fun refreshOnlineModTable() { - if (runningSearchThread != null) return // cowardice: prevent concurrent modification, avoid a manager layer + if (runningSearchJob != null) return // cowardice: prevent concurrent modification, avoid a manager layer val newHeaderText = optionsManager.getOnlineHeader() onlineHeaderLabel?.setText(newHeaderText) diff --git a/core/src/com/unciv/ui/popup/ToastPopup.kt b/core/src/com/unciv/ui/popup/ToastPopup.kt index 1650738ec8..078c76a62f 100644 --- a/core/src/com/unciv/ui/popup/ToastPopup.kt +++ b/core/src/com/unciv/ui/popup/ToastPopup.kt @@ -1,9 +1,10 @@ package com.unciv.ui.popup +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.utils.BaseScreen -import com.unciv.ui.crashhandling.crashHandlingThread import com.unciv.ui.utils.onClick import com.unciv.ui.crashhandling.postCrashHandlingRunnable +import kotlinx.coroutines.delay /** * This is an unobtrusive popup which will close itself after a given amount of time. @@ -23,9 +24,9 @@ class ToastPopup (message: String, screen: BaseScreen, val time: Long = 2000) : } private fun startTimer(){ - crashHandlingThread(name = "ResponsePopup") { - Thread.sleep(time) - postCrashHandlingRunnable { this.close() } + launchCrashHandling("ResponsePopup") { + delay(time) + postCrashHandlingRunnable { this@ToastPopup.close() } } } diff --git a/core/src/com/unciv/ui/saves/LoadGameScreen.kt b/core/src/com/unciv/ui/saves/LoadGameScreen.kt index 5f9f162a64..ef8e9db4a1 100644 --- a/core/src/com/unciv/ui/saves/LoadGameScreen.kt +++ b/core/src/com/unciv/ui/saves/LoadGameScreen.kt @@ -14,7 +14,7 @@ import com.unciv.logic.MissingModsException import com.unciv.logic.UncivShowableException import com.unciv.models.ruleset.RulesetCache import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.pickerscreens.Github @@ -51,7 +51,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t val loadingPopup = Popup( this) loadingPopup.addGoodSizedLabel("Loading...") loadingPopup.open() - crashHandlingThread(name = "Load Game") { + launchCrashHandling("Load Game") { try { // This is what can lead to ANRs - reading the file and setting the transients, that's why this is in another thread val loadedGame = GameSaver.loadGameByName(selectedSave) @@ -59,7 +59,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t } catch (ex: Exception) { postCrashHandlingRunnable { loadingPopup.close() - val cantLoadGamePopup = Popup(this) + val cantLoadGamePopup = Popup(this@LoadGameScreen) cantLoadGamePopup.addGoodSizedLabel("It looks like your saved game can't be loaded!").row() if (ex is UncivShowableException && ex.localizedMessage != null) { // thrown exceptions are our own tests and can be shown to the user @@ -155,7 +155,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t private fun loadMissingMods() { loadMissingModsButton.isEnabled = false descriptionLabel.setText("Loading...".tr()) - crashHandlingThread(name="DownloadMods") { + launchCrashHandling("DownloadMods", runAsDaemon = false) { try { val mods = missingModsToLoad.replace(' ', '-').lowercase().splitToSequence(",-") for (modName in mods) { @@ -175,7 +175,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t missingModsToLoad = "" loadMissingModsButton.isVisible = false errorLabel.setText("") - ToastPopup("Missing mods are downloaded successfully.", this) + ToastPopup("Missing mods are downloaded successfully.", this@LoadGameScreen) } } catch (ex: Exception) { handleLoadGameException("Could not load the missing mods!", ex) @@ -205,8 +205,9 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t loadImage.addAction(Actions.rotateBy(360f, 2f)) saveTable.add(loadImage).size(50f) - crashHandlingThread { // Apparently, even jut getting the list of saves can cause ANRs - - // not sure how many saves these guys had but Google Play reports this to have happened hundreds of times + // Apparently, even just getting the list of saves can cause ANRs - + // not sure how many saves these guys had but Google Play reports this to have happened hundreds of times + launchCrashHandling("GetSaves") { // .toList() because otherwise the lastModified will only be checked inside the postRunnable val saves = GameSaver.getSaves().sortedByDescending { it.lastModified() }.toList() @@ -235,7 +236,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t val savedAt = Date(save.lastModified()) var textToSet = save.name() + "\n${"Saved at".tr()}: " + savedAt.formatDate() - crashHandlingThread { // Even loading the game to get its metadata can take a long time on older phones + launchCrashHandling("LoadMetaData") { // Even loading the game to get its metadata can take a long time on older phones try { val game = GameSaver.loadGamePreviewFromFile(save) val playerCivNames = game.civilizations.filter { it.isPlayerCivilization() }.joinToString { it.civName.tr() } diff --git a/core/src/com/unciv/ui/saves/SaveGameScreen.kt b/core/src/com/unciv/ui/saves/SaveGameScreen.kt index 7bb9bcb0ed..56aae0293d 100644 --- a/core/src/com/unciv/ui/saves/SaveGameScreen.kt +++ b/core/src/com/unciv/ui/saves/SaveGameScreen.kt @@ -9,7 +9,7 @@ import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.GameSaver import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.pickerscreens.PickerScreen import com.unciv.ui.popup.ToastPopup @@ -60,7 +60,7 @@ class SaveGameScreen(val gameInfo: GameInfo) : PickerScreen(disableScroll = true errorLabel.setText("") saveToCustomLocation.setText("Saving...".tr()) saveToCustomLocation.disable() - crashHandlingThread(name = "SaveGame") { + launchCrashHandling("SaveGame", runAsDaemon = false) { GameSaver.saveGameToCustomLocation(gameInfo, gameNameTextField.text) { e -> if (e == null) { postCrashHandlingRunnable { game.setWorldScreen() } @@ -97,10 +97,10 @@ class SaveGameScreen(val gameInfo: GameInfo) : PickerScreen(disableScroll = true private fun saveGame() { rightSideButton.setText("Saving...".tr()) - crashHandlingThread(name = "SaveGame") { + launchCrashHandling("SaveGame", runAsDaemon = false) { GameSaver.saveGame(gameInfo, gameNameTextField.text) { postCrashHandlingRunnable { - if (it != null) ToastPopup("Could not save game!", this) + if (it != null) ToastPopup("Could not save game!", this@SaveGameScreen) else UncivGame.Current.setWorldScreen() } } diff --git a/core/src/com/unciv/ui/utils/BaseScreen.kt b/core/src/com/unciv/ui/utils/BaseScreen.kt index 5fc3795496..64796f6dfd 100644 --- a/core/src/com/unciv/ui/utils/BaseScreen.kt +++ b/core/src/com/unciv/ui/utils/BaseScreen.kt @@ -124,7 +124,7 @@ abstract class BaseScreen : Screen { /** @return `true` if the screen is narrower than 4:3 landscape */ fun isNarrowerThan4to3() = stage.viewport.screenHeight * 4 > stage.viewport.screenWidth * 3 - fun openOptionsPopup(startingPage: Int = OptionsPopup.defaultPage) { - OptionsPopup(this, startingPage).open(force = true) + fun openOptionsPopup(startingPage: Int = OptionsPopup.defaultPage, onClose: () -> Unit = {}) { + OptionsPopup(this, startingPage, onClose).open(force = true) } } diff --git a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt index 3f60ca37fb..0488d7f55f 100644 --- a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt +++ b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt @@ -13,7 +13,7 @@ import com.unciv.UncivGame import com.unciv.models.UncivSound import com.unciv.models.translations.tr import com.unciv.ui.audio.Sounds -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.images.IconCircleGroup import com.unciv.ui.images.ImageGetter import java.text.SimpleDateFormat @@ -67,7 +67,7 @@ fun Actor.center(parent: Stage){ centerX(parent); centerY(parent)} fun Actor.onClickEvent(sound: UncivSound = UncivSound.Click, function: (event: InputEvent?, x: Float, y: Float) -> Unit) { this.addListener(object : ClickListener() { override fun clicked(event: InputEvent?, x: Float, y: Float) { - crashHandlingThread(name = "Sound") { Sounds.play(sound) } + launchCrashHandling("Sound") { Sounds.play(sound) } function(event, x, y) } }) diff --git a/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt b/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt index b656901908..2c6f054e84 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt @@ -26,7 +26,7 @@ import com.unciv.models.* import com.unciv.models.helpers.MapArrowType import com.unciv.models.helpers.MiscArrowTypes import com.unciv.ui.audio.Sounds -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.map.TileGroupMap @@ -115,7 +115,7 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap override fun clicked(event: InputEvent?, x: Float, y: Float) { val unit = worldScreen.bottomUnitTable.selectedUnit ?: return - crashHandlingThread { + launchCrashHandling("WorldScreenClick") { val tile = tileGroup.tileInfo if (worldScreen.bottomUnitTable.selectedUnitIsSwapping) { @@ -123,7 +123,7 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap swapMoveUnitToTargetTile(unit, tile) } // If we are in unit-swapping mode, we don't want to move or attack - return@crashHandlingThread + return@launchCrashHandling } val attackableTile = BattleHelper.getAttackableEnemies(unit, unit.movement.getDistanceToTiles()) @@ -131,13 +131,13 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap if (unit.canAttack() && attackableTile != null) { Battle.moveAndAttack(MapUnitCombatant(unit), attackableTile) worldScreen.shouldUpdate = true - return@crashHandlingThread + return@launchCrashHandling } val canUnitReachTile = unit.movement.canReach(tile) if (canUnitReachTile) { moveUnitToTargetTile(listOf(unit), tile) - return@crashHandlingThread + return@launchCrashHandling } } } @@ -214,7 +214,7 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap val selectedUnit = selectedUnits.first() - crashHandlingThread(name = "TileToMoveTo") { + launchCrashHandling("TileToMoveTo") { // these are the heavy parts, finding where we want to go // Since this runs in a different thread, even if we check movement.canReach() // then it might change until we get to the getTileToMoveTo, so we just try/catch it @@ -224,7 +224,7 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap } catch (ex: Exception) { println("Exception in getTileToMoveToThisTurn: ${ex.message}") ex.printStackTrace() - return@crashHandlingThread + return@launchCrashHandling } // can't move here postCrashHandlingRunnable { @@ -270,7 +270,7 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap } private fun addTileOverlaysWithUnitMovement(selectedUnits: List, tileInfo: TileInfo) { - crashHandlingThread(name = "TurnsToGetThere") { + launchCrashHandling("TurnsToGetThere") { /** LibGdx sometimes has these weird errors when you try to edit the UI layout from 2 separate threads. * And so, all UI editing will be done on the main thread. * The only "heavy lifting" that needs to be done is getting the turns to get there, diff --git a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt index 0447178d6f..d56e431e05 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt @@ -1,5 +1,7 @@ package com.unciv.ui.worldscreen +import com.unciv.ui.worldscreen.status.NextTurnAction +import com.unciv.ui.worldscreen.status.NextTurnButton import com.badlogic.gdx.Gdx import com.badlogic.gdx.Input import com.badlogic.gdx.graphics.Color @@ -21,7 +23,6 @@ import com.unciv.logic.civilization.CivilizationInfo import com.unciv.logic.civilization.ReligionState import com.unciv.logic.civilization.diplomacy.DiplomaticStatus import com.unciv.logic.map.MapVisualization -import com.unciv.logic.multiplayer.FileStorageRateLimitReached import com.unciv.logic.trade.TradeEvaluation import com.unciv.models.Tutorial import com.unciv.models.UncivSound @@ -40,8 +41,10 @@ import com.unciv.ui.utils.UncivDateFormat.formatDate import com.unciv.ui.victoryscreen.VictoryScreen import com.unciv.ui.worldscreen.bottombar.BattleTable import com.unciv.ui.worldscreen.bottombar.TileInfoTable -import com.unciv.logic.multiplayer.OnlineMultiplayer -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached +import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver +import com.unciv.ui.crashhandling.CRASH_HANDLING_DAEMON_SCOPE +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.popup.ExitGamePopup @@ -51,6 +54,11 @@ import com.unciv.ui.popup.hasOpenPopups import com.unciv.ui.worldscreen.minimap.MinimapHolder import com.unciv.ui.worldscreen.unit.UnitActionsTable import com.unciv.ui.worldscreen.unit.UnitTable +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn import java.util.* import kotlin.concurrent.timer @@ -90,8 +98,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas private val techButtonHolder = Table() private val diplomacyButtonHolder = Table() private val fogOfWarButton = createFogOfWarButton() - private val nextTurnButton = createNextTurnButton() - private var nextTurnAction: () -> Unit = {} + private val nextTurnButton = NextTurnButton(keyPressDispatcher) private val tutorialTaskTable = Table().apply { background = ImageGetter.getBackground( ImageGetter.getBlue().darken(0.5f)) } @@ -102,8 +109,9 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas /** Switch for console logging of next turn duration */ private const val consoleLog = false + private lateinit var multiPlayerRefresher: Flow // this object must not be created multiple times - private var multiPlayerRefresher: Timer? = null + private var multiPlayerRefresherJob: Job? = null } init { @@ -195,11 +203,13 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas // restart the timer stopMultiPlayerRefresher() - // isDaemon = true, in order to not block the app closing - // DO NOT use Timer() since this seems to (maybe?) translate to com.badlogic.gdx.utils.Timer? Not sure about this. - multiPlayerRefresher = timer("multiPlayerRefresh", true, period = 10000) { - loadLatestMultiplayerState() + multiPlayerRefresher = flow { + while (true) { + loadLatestMultiplayerState() + delay(10000) + } } + multiPlayerRefresherJob = multiPlayerRefresher.launchIn(CRASH_HANDLING_DAEMON_SCOPE) } // don't run update() directly, because the UncivGame.worldScreen should be set so that the city buttons and tile groups @@ -208,9 +218,8 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } private fun stopMultiPlayerRefresher() { - if (multiPlayerRefresher != null) { - multiPlayerRefresher?.cancel() - multiPlayerRefresher?.purge() + if (multiPlayerRefresherJob != null) { + multiPlayerRefresherJob?.cancel() } } @@ -219,14 +228,14 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas // GameSaver.autoSave, SaveGameScreen.saveGame, LoadGameScreen.rightSideButton.onClick,... val quickSave = { val toast = ToastPopup("Quicksaving...", this) - crashHandlingThread(name = "SaveGame") { + launchCrashHandling("SaveGame", runAsDaemon = false) { GameSaver.saveGame(gameInfo, "QuickSave") { postCrashHandlingRunnable { toast.close() if (it != null) - ToastPopup("Could not save game!", this) + ToastPopup("Could not save game!", this@WorldScreen) else { - ToastPopup("Quicksave successful.", this) + ToastPopup("Quicksave successful.", this@WorldScreen) } } } @@ -235,17 +244,17 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } val quickLoad = { val toast = ToastPopup("Quickloading...", this) - crashHandlingThread(name = "SaveGame") { + launchCrashHandling("LoadGame") { try { val loadedGame = GameSaver.loadGameByName("QuickSave") postCrashHandlingRunnable { toast.close() UncivGame.Current.loadGame(loadedGame) - ToastPopup("Quickload successful.", this) + ToastPopup("Quickload successful.", this@WorldScreen) } } catch (ex: Exception) { postCrashHandlingRunnable { - ToastPopup("Could not load game!", this) + ToastPopup("Could not load game!", this@WorldScreen) } } } @@ -276,7 +285,12 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas if (!mapHolder.setCenterPosition(capital.location)) game.setScreen(CityScreen(capital)) } - keyPressDispatcher[KeyCharAndCode.ctrl('O')] = { this.openOptionsPopup() } // Game Options + keyPressDispatcher[KeyCharAndCode.ctrl('O')] = { // Game Options + this.openOptionsPopup(onClose = { + mapHolder.reloadMaxZoom() + nextTurnButton.update(hasOpenPopups(), isPlayersTurn, waitingForAutosave) + }) + } keyPressDispatcher[KeyCharAndCode.ctrl('S')] = { game.setScreen(SaveGameScreen(gameInfo)) } // Save keyPressDispatcher[KeyCharAndCode.ctrl('L')] = { game.setScreen(LoadGameScreen(this)) } // Load keyPressDispatcher[KeyCharAndCode.ctrl('Q')] = { ExitGamePopup(this, true) } // Quit @@ -339,7 +353,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } - private fun loadLatestMultiplayerState() { + private suspend fun loadLatestMultiplayerState() { // Since we're on a background thread, all the UI calls in this func need to run from the // main thread which has a GL context val loadingGamePopup = Popup(this) @@ -349,13 +363,13 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } try { - val latestGame = OnlineMultiplayer().tryDownloadGame(gameInfo.gameId) + val latestGame = OnlineMultiplayerGameSaver().tryDownloadGame(gameInfo.gameId) // if we find the current player didn't change, don't update // Additionally, check if we are the current player, and in that case always stop // This fixes a bug where for some reason players were waiting for themselves. - if (gameInfo.currentPlayer == latestGame.currentPlayer - && gameInfo.turns == latestGame.turns + if (gameInfo.currentPlayer == latestGame.currentPlayer + && gameInfo.turns == latestGame.turns && latestGame.currentPlayer != gameInfo.getPlayerToViewAs().civName ) { postCrashHandlingRunnable { loadingGamePopup.close() } @@ -380,10 +394,8 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas stopMultiPlayerRefresher() val restartAfter : Long = ex.limitRemainingSeconds.toLong() * 1000 - timer("RestartTimerTimer", true, restartAfter, 0 ) { - multiPlayerRefresher = timer("multiPlayerRefresh", true, period = 10000) { - loadLatestMultiplayerState() - } + timer("RestartTimerTimer", true, restartAfter, 0) { + multiPlayerRefresherJob = multiPlayerRefresher.launchIn(CRASH_HANDLING_DAEMON_SCOPE) } } catch (ex: Throwable) { postCrashHandlingRunnable { @@ -622,20 +634,6 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } - private fun createNextTurnButton(): TextButton { - - val nextTurnButton = TextButton("", skin) // text is set in update() - nextTurnButton.label.setFontSize(30) - nextTurnButton.labelCell.pad(10f) - val nextTurnActionWrapped = { nextTurnAction() } - nextTurnButton.onClick(nextTurnActionWrapped) - keyPressDispatcher[Input.Keys.SPACE] = nextTurnActionWrapped - keyPressDispatcher['n'] = nextTurnActionWrapped - - return nextTurnButton - } - - private fun createNewWorldScreen(gameInfo: GameInfo) { game.gameInfo = gameInfo @@ -661,8 +659,8 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas isPlayersTurn = false shouldUpdate = true - - crashHandlingThread(name = "NextTurn") { // on a separate thread so the user can explore their world while we're passing the turn + // on a separate thread so the user can explore their world while we're passing the turn + launchCrashHandling("NextTurn", runAsDaemon = false) { if (consoleLog) println("\nNext turn starting " + Date().formatDate()) val startTime = System.currentTimeMillis() @@ -674,31 +672,31 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas if (originalGameInfo.gameParameters.isOnlineMultiplayer) { try { - OnlineMultiplayer().tryUploadGame(gameInfoClone, withPreview = true) + OnlineMultiplayerGameSaver().tryUploadGame(gameInfoClone, withPreview = true) } catch (ex: FileStorageRateLimitReached) { postCrashHandlingRunnable { - val cantUploadNewGamePopup = Popup(this) + val cantUploadNewGamePopup = Popup(this@WorldScreen) cantUploadNewGamePopup.addGoodSizedLabel("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds").row() cantUploadNewGamePopup.addCloseButton() cantUploadNewGamePopup.open() } } catch (ex: Exception) { postCrashHandlingRunnable { // Since we're changing the UI, that should be done on the main thread - val cantUploadNewGamePopup = Popup(this) + val cantUploadNewGamePopup = Popup(this@WorldScreen) cantUploadNewGamePopup.addGoodSizedLabel("Could not upload game!").row() cantUploadNewGamePopup.addCloseButton() cantUploadNewGamePopup.open() } - isPlayersTurn = true // Since we couldn't push the new game clone, then it's like we never clicked the "next turn" button - shouldUpdate = true - return@crashHandlingThread + this@WorldScreen.isPlayersTurn = true // Since we couldn't push the new game clone, then it's like we never clicked the "next turn" button + this@WorldScreen.shouldUpdate = true + return@launchCrashHandling } } if (game.gameInfo != originalGameInfo) // while this was turning we loaded another game - return@crashHandlingThread + return@launchCrashHandling - game.gameInfo = gameInfoClone + this@WorldScreen.game.gameInfo = gameInfoClone if (consoleLog) println("Next turn took ${System.currentTimeMillis()-startTime}ms") @@ -716,7 +714,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } if (shouldAutoSave) { - val newWorldScreen = game.worldScreen + val newWorldScreen = this@WorldScreen.game.worldScreen newWorldScreen.waitingForAutosave = true newWorldScreen.shouldUpdate = true GameSaver.autoSave(gameInfoClone) { @@ -729,27 +727,11 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } } - private class NextTurnAction(val text: String, val color: Color, val action: () -> Unit) - private fun updateNextTurnButton(isSomethingOpen: Boolean) { - val action: NextTurnAction = getNextTurnAction() - nextTurnAction = action.action - - nextTurnButton.setText(action.text.tr()) - nextTurnButton.label.color = action.color - nextTurnButton.pack() - nextTurnButton.isEnabled = !isSomethingOpen && isPlayersTurn && !waitingForAutosave + nextTurnButton.update(isSomethingOpen, isPlayersTurn, waitingForAutosave, getNextTurnAction()) nextTurnButton.setPosition(stage.width - nextTurnButton.width - 10f, topBar.y - nextTurnButton.height - 10f) } - /** - * Used by [OptionsPopup][com.unciv.ui.worldscreen.mainmenu.OptionsPopup] - * to re-enable the next turn button within its Close button action - */ - fun enableNextTurnButtonAfterOptions() { - mapHolder.reloadMaxZoom() - nextTurnButton.isEnabled = isPlayersTurn && !waitingForAutosave - } private fun getNextTurnAction(): NextTurnAction { return when { @@ -832,7 +814,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas viewingCiv.hasMovedAutomatedUnits = true isPlayersTurn = false // Disable state changes nextTurnButton.disable() - crashHandlingThread(name="Move automated units") { + launchCrashHandling("Move automated units") { for (unit in viewingCiv.getCivUnits()) unit.doAction() postCrashHandlingRunnable { diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt index 4db915403b..81b6b04f91 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt @@ -13,7 +13,7 @@ import com.unciv.UncivGame import com.unciv.logic.GameSaver import com.unciv.logic.MapSaver import com.unciv.logic.civilization.PlayerType -import com.unciv.logic.multiplayer.SimpleHttp +import com.unciv.logic.multiplayer.storage.SimpleHttp import com.unciv.models.UncivSound import com.unciv.models.metadata.BaseRuleset import com.unciv.models.ruleset.Ruleset @@ -29,7 +29,7 @@ import com.unciv.models.translations.tr import com.unciv.ui.audio.MusicTrackChooserFlags import com.unciv.ui.civilopedia.FormattedLine import com.unciv.ui.civilopedia.MarkupRenderer -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.newgamescreen.TranslatedSelectBox @@ -52,7 +52,8 @@ import com.badlogic.gdx.utils.Array as GdxArray //region Fields class OptionsPopup( private val previousScreen: BaseScreen, - private val selectPage: Int = defaultPage + private val selectPage: Int = defaultPage, + private val onClose: () -> Unit = {} ) : Popup(previousScreen) { private val settings = previousScreen.game.settings private val tabs: TabbedPager @@ -109,8 +110,7 @@ class OptionsPopup( addCloseButton { previousScreen.game.musicController.onChange(null) previousScreen.game.platformSpecificHelper?.allowPortrait(settings.allowAndroidPortrait) - if (previousScreen is WorldScreen) - previousScreen.enableNextTurnButtonAfterOptions() + onClose() }.padBottom(10f) pack() // Needed to show the background. @@ -136,7 +136,7 @@ class OptionsPopup( (previousScreen.game.screen as BaseScreen).openOptionsPopup(tabs.activePage) } - private fun successfullyConnectedToServer(action: (Boolean, String)->Unit){ + private fun successfullyConnectedToServer(action: (Boolean, String, Int?) -> Unit){ SimpleHttp.sendGetRequest("${settings.multiplayerServer}/isalive", action) } @@ -300,7 +300,7 @@ class OptionsPopup( } popup.open(true) - successfullyConnectedToServer { success: Boolean, _: String -> + successfullyConnectedToServer { success, _, _ -> popup.addGoodSizedLabel(if (success) "Success!" else "Failed!").row() popup.addCloseButton() } @@ -387,7 +387,7 @@ class OptionsPopup( modCheckResultTable.add("Checking mods for errors...".toLabel()).row() modCheckBaseSelect!!.isDisabled = true - crashHandlingThread(name="ModChecker") { + launchCrashHandling("ModChecker") { for (mod in RulesetCache.values.sortedBy { it.name }) { if (base != modCheckWithoutBase && mod.modOptions.isBaseRuleset) continue @@ -800,7 +800,7 @@ class OptionsPopup( errorTable.add("Downloading...".toLabel()) // So the whole game doesn't get stuck while downloading the file - crashHandlingThread(name = "Music") { + launchCrashHandling("MusicDownload") { try { previousScreen.game.musicController.downloadDefaultFile() postCrashHandlingRunnable { @@ -917,7 +917,7 @@ class OptionsPopup( } } - crashHandlingThread(name = "Add Font Select") { + launchCrashHandling("Add Font Select") { // This is a heavy operation and causes ANRs val fonts = GdxArray().apply { add(FontFamilyData.default) @@ -936,7 +936,7 @@ class OptionsPopup( val generateAction: ()->Unit = { tabs.selectPage("Advanced") generateTranslationsButton.setText("Working...".tr()) - crashHandlingThread { + launchCrashHandling("WriteTranslations") { val result = TranslationFileWriter.writeNewTranslationFiles() postCrashHandlingRunnable { // notify about completion diff --git a/core/src/com/unciv/ui/worldscreen/status/NextTurnButton.kt b/core/src/com/unciv/ui/worldscreen/status/NextTurnButton.kt new file mode 100644 index 0000000000..b6adecb22b --- /dev/null +++ b/core/src/com/unciv/ui/worldscreen/status/NextTurnButton.kt @@ -0,0 +1,34 @@ +package com.unciv.ui.worldscreen.status + +import com.badlogic.gdx.Input +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.unciv.models.translations.tr +import com.unciv.ui.utils.* + +class NextTurnButton( + keyPressDispatcher: KeyPressDispatcher +) : TextButton("", BaseScreen.skin) { + lateinit var nextTurnAction: NextTurnAction + init { + label.setFontSize(30) + labelCell.pad(10f) + val action = { nextTurnAction.action() } + onClick(action) + keyPressDispatcher[Input.Keys.SPACE] = action + keyPressDispatcher['n'] = action + } + + fun update(isSomethingOpen: Boolean, isPlayersTurn: Boolean, waitingForAutosave: Boolean, nextTurnAction: NextTurnAction? = null) { + if (nextTurnAction != null) { + this.nextTurnAction = nextTurnAction + setText(nextTurnAction.text.tr()) + label.color = nextTurnAction.color + pack() + } + + isEnabled = !isSomethingOpen && isPlayersTurn && !waitingForAutosave + } +} + +class NextTurnAction(val text: String, val color: Color, val action: () -> Unit) \ No newline at end of file diff --git a/core/src/com/unciv/ui/worldscreen/unit/UnitActionsTable.kt b/core/src/com/unciv/ui/worldscreen/unit/UnitActionsTable.kt index 75af10604f..3608d71563 100644 --- a/core/src/com/unciv/ui/worldscreen/unit/UnitActionsTable.kt +++ b/core/src/com/unciv/ui/worldscreen/unit/UnitActionsTable.kt @@ -7,7 +7,7 @@ import com.unciv.UncivGame import com.unciv.logic.map.MapUnit import com.unciv.models.UnitAction import com.unciv.ui.audio.Sounds -import com.unciv.ui.crashhandling.crashHandlingThread +import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.images.IconTextButton import com.unciv.ui.utils.* import com.unciv.ui.utils.KeyPressDispatcher.Companion.keyboardAvailable @@ -44,7 +44,7 @@ class UnitActionsTable(val worldScreen: WorldScreen) : Table() { actionButton.onClick(unitAction.uncivSound, action) if (key != KeyCharAndCode.UNKNOWN) worldScreen.keyPressDispatcher[key] = { - crashHandlingThread(name = "Sound") { Sounds.play(unitAction.uncivSound) } + launchCrashHandling("UnitSound") { Sounds.play(unitAction.uncivSound) } action() worldScreen.mapHolder.removeUnitActionOverlay() } diff --git a/server/src/com/unciv/app/server/UncivServer.kt b/server/src/com/unciv/app/server/UncivServer.kt index a660f88e3f..c4d869b2b2 100644 --- a/server/src/com/unciv/app/server/UncivServer.kt +++ b/server/src/com/unciv/app/server/UncivServer.kt @@ -6,6 +6,7 @@ import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.int import com.github.ajalt.clikt.parameters.types.restrictTo import io.ktor.application.* +import io.ktor.http.* import io.ktor.response.* import io.ktor.routing.* import io.ktor.server.engine.* @@ -14,6 +15,7 @@ import io.ktor.utils.io.jvm.javaio.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File +import java.io.FileNotFoundException internal object UncivServer { @@ -60,7 +62,10 @@ private class UncivServerRunner : CliktCommand() { val fileName = call.parameters["fileName"] ?: throw Exception("No fileName!") println("Get file: $fileName") val file = File(fileFolderName, fileName) - if (!file.exists()) throw Exception("File does not exist!") + if (!file.exists()) { + call.respond(HttpStatusCode.NotFound, "File does not exist") + return@get + } val fileText = file.readText() println("Text read: $fileText") call.respondText(fileText) @@ -68,7 +73,10 @@ private class UncivServerRunner : CliktCommand() { delete("/files/{fileName}") { val fileName = call.parameters["fileName"] ?: throw Exception("No fileName!") val file = File(fileFolderName, fileName) - if (!file.exists()) throw Exception("File does not exist!") + if (!file.exists()) { + call.respond(HttpStatusCode.NotFound, "File does not exist") + return@delete + } file.delete() } }