diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 144b0489a9..83be381047 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -31,9 +31,6 @@ Conquer a city!\nBring an enemy city down to low health > \nEnter the city with Move an air unit!\nSelect an air unit > select another city within range > \nMove the unit to the other city = See your stats breakdown!\nEnter the Overview screen (top right corner) >\nClick on 'Stats' = -Oh no! It looks like something went DISASTROUSLY wrong! This is ABSOLUTELY not supposed to happen! Please send me (yairm210@hotmail.com) an email with the game information (menu -> save game -> copy game info -> paste into email) and I'll try to fix it as fast as I can! = -Oh no! It looks like something went DISASTROUSLY wrong! This is ABSOLUTELY not supposed to happen! Please send us an report and we'll try to fix it as fast as we can! = - # Crash screen An unrecoverable error has occurred in Unciv: = diff --git a/android/src/com/unciv/app/AndroidLauncher.kt b/android/src/com/unciv/app/AndroidLauncher.kt index bc913496b1..8d62898dbc 100644 --- a/android/src/com/unciv/app/AndroidLauncher.kt +++ b/android/src/com/unciv/app/AndroidLauncher.kt @@ -39,7 +39,7 @@ open class AndroidLauncher : AndroidApplication() { } val androidParameters = UncivGameParameters( version = BuildConfig.VERSION_NAME, - crashReportSender = CrashReportSenderAndroid(this), + crashReportSysInfo = CrashReportSysInfoAndroid, fontImplementation = NativeFontAndroid(Fonts.ORIGINAL_FONT_SIZE.toInt()), customSaveLocationHelper = customSaveLocationHelper, limitOrientationsHelper = limitOrientationsHelper diff --git a/android/src/com/unciv/app/CrashReportSenderAndroid.kt b/android/src/com/unciv/app/CrashReportSenderAndroid.kt deleted file mode 100644 index 6e262ba5fc..0000000000 --- a/android/src/com/unciv/app/CrashReportSenderAndroid.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.unciv.app - -import android.app.Activity -import android.content.ActivityNotFoundException -import android.content.Intent -import android.os.Build -import android.widget.Toast -import com.unciv.models.CrashReport -import com.unciv.ui.utils.CrashReportSender - -class CrashReportSenderAndroid(private val activity: Activity) : CrashReportSender { - - companion object { - private const val EMAIL_TO = "yairm210@hotmail.com" - private const val CHOOSER_TITLE = "Send mail" - private const val CANNOT_SEND_EMAIL = "There are no email clients installed." - private const val EMAIL_TITLE = "Crash report" - private const val EMAIL_BODY = "\n--------------------------------\n" + - "Game version: %s\n" + - "OS version: %s\n" + - "Device model: %s\n" + - "Mods: %s\n" + - "Game data: \n%s\n" - } - - override fun sendReport(report: CrashReport) { - activity.runOnUiThread { - try { - activity.startActivity(Intent.createChooser(prepareIntent(report), CHOOSER_TITLE)) - } catch (ex: ActivityNotFoundException) { - Toast.makeText(activity, CANNOT_SEND_EMAIL, Toast.LENGTH_SHORT).show() - } - } - } - - private fun prepareIntent(report: CrashReport) = Intent(Intent.ACTION_SEND).apply { - type = "message/rfc822" - putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_TO)) - putExtra(Intent.EXTRA_SUBJECT, "$EMAIL_TITLE - ${report.version}") - putExtra(Intent.EXTRA_TEXT, buildEmailBody(report)) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - - private fun buildEmailBody(report: CrashReport): String = - EMAIL_BODY.format(report.version, Build.VERSION.SDK_INT, Build.MODEL, report.mods.joinToString(), report.gameInfo) -} \ No newline at end of file diff --git a/android/src/com/unciv/app/CrashReportSysInfoAndroid.kt b/android/src/com/unciv/app/CrashReportSysInfoAndroid.kt new file mode 100644 index 0000000000..62719bd9bc --- /dev/null +++ b/android/src/com/unciv/app/CrashReportSysInfoAndroid.kt @@ -0,0 +1,13 @@ +package com.unciv.app + +import android.os.Build +import com.unciv.ui.utils.CrashReportSysInfo + +object CrashReportSysInfoAndroid: CrashReportSysInfo { + + override fun getInfo(): String = + """ + Device Model: ${Build.MODEL} + API Level: ${Build.VERSION.SDK_INT} + """.trimIndent() +} diff --git a/core/src/com/unciv/CrashHandlingStage.kt b/core/src/com/unciv/CrashHandlingStage.kt index 57083d231d..86e1489a79 100644 --- a/core/src/com/unciv/CrashHandlingStage.kt +++ b/core/src/com/unciv/CrashHandlingStage.kt @@ -5,6 +5,7 @@ import com.badlogic.gdx.scenes.scene2d.Stage import com.badlogic.gdx.utils.viewport.Viewport import com.unciv.ui.utils.* + /** Stage that safely brings the game to a [CrashScreen] if any event handlers throw an exception or an error that doesn't get otherwise handled. */ class CrashHandlingStage: Stage { constructor(): super() @@ -38,11 +39,11 @@ class CrashHandlingStage: Stage { // Another stack trace from an exception after setting TileInfo.naturalWonder to an invalid value is below that. -// Below that are another two exceptions from a lambda given to Gdx.app.postRunnable{} and another to thread{}. +// Below that are another two exceptions, from a lambda given to thread{} and another given to Gdx.app.postRunnable{}. // Stage()'s event handlers seem to be the most universal place to intercept exceptions from events. -// Events and the render loop are the main ways that code gets run with GDX, right? So if we wrap both of those in exception handling, it should hopefully gracefully catch most unhandled exceptions… Threads may be the exception, hence why I put the wrapping as extension functions that can be invoked on the lambdas passed to threads. +// Events and the render loop are the main ways that code gets run with GDX, right? So if we wrap both of those in exception handling, it should hopefully gracefully catch most unhandled exceptions… Threads may be the exception, hence why I put the wrapping as extension functions that can be invoked on the lambdas passed to threads, as in crashHandlingThread and postCrashHandlingRunnable. // Button click (event): diff --git a/core/src/com/unciv/CrashScreen.kt b/core/src/com/unciv/CrashScreen.kt index 2ff18d22e3..312747cc5e 100644 --- a/core/src/com/unciv/CrashScreen.kt +++ b/core/src/com/unciv/CrashScreen.kt @@ -6,12 +6,24 @@ import com.badlogic.gdx.scenes.scene2d.Actor import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Align +import com.badlogic.gdx.utils.Json import com.unciv.models.ruleset.RulesetCache +import com.unciv.ui.saves.Gzip import com.unciv.ui.utils.* import java.io.PrintWriter import java.io.StringWriter import kotlin.concurrent.thread +/* +Crashes are now handled from: +- Event listeners, by [CrashHandlingStage]. +- The main rendering loop, by [UncivGame.render]. +- Threads, by [crashHandlingThread]. +- Main loop runnables, by [postCrashHandlingRunnable]. + +Altogether, I *think* that should cover 90%-99% of all potential crashes. + */ + /** Screen to crash to when an otherwise unhandled exception or error is thrown. */ class CrashScreen(val exception: Throwable): BaseScreen() { @@ -23,17 +35,79 @@ class CrashScreen(val exception: Throwable): BaseScreen() { } } - val text = generateReportHeader() + exception.stringify() + /** Qualified class name of the game screen that was active at the construction of this instance, or an error note. */ + val lastScreenType = try { + UncivGame.Current.screen::class.qualifiedName.toString() + } catch (e: Throwable) { + "Could not get screen type: $e" + } + + val text = formatReport(exception.stringify()) var copied = false private set - fun generateReportHeader(): String { + /** @return The last active save game serialized as a compressed string if any, or an informational note otherwise. */ + private fun tryGetSaveGame() + = try { + UncivGame.Current.gameInfo.let { gameInfo -> + Json().toJson(gameInfo).let { + jsonString -> Gzip.zip(jsonString) + } + } // Taken from old CrashController().buildReport(). + } catch (e: Throwable) { + "No save data: $e" // In theory .toString() could still error here. + } + + /** @return Mods from the last active save game if any, or an informational note otherwise. */ + private fun tryGetSaveMods() + = try { // Also from old CrashController().buildReport(), also could still error at .toString(). + LinkedHashSet(UncivGame.Current.gameInfo.gameParameters.getModsAndBaseRuleset()).toString() + } catch (e: Throwable) { + "No mod data: $e" + } + + + /** + * @param message Error text. Probably exception traceback. + * @return Message with application, platform, and game state metadata. + * */ + private fun formatReport(message: String): String { + val indent = " ".repeat(4) + val baseIndent = indent.repeat(3) // To be even with the template string. + val subIndent = baseIndent + indent // TO be one level more than the template string. + /** We only need the indent after any new lines in each substitution itself. So this prepends to all lines, and then removes from the start. */ + fun String.prependIndentToOnlyNewLines(indent: String) = this.prependIndent(indent).removePrefix(indent) + /// The $lastScreenType substitution is the only one completely under the control of this class— Everything else can, in theory, have new lines in it due to containing strings or custom .toString behaviour with new lines (which… I think Table.toString or something actually does). So normalize indentation for basically everything. return """ - Platform: ${Gdx.app.type} - Version: ${UncivGame.Current.version} - Rulesets: ${RulesetCache.keys} + **Platform:** ${Gdx.app.type.toString().prependIndentToOnlyNewLines(subIndent)} + **Version:** ${UncivGame.Current.version.prependIndentToOnlyNewLines(subIndent)} + **Rulesets:** ${RulesetCache.keys.toString().prependIndentToOnlyNewLines(subIndent)} + **Last Screen:** `$lastScreenType` + + -------------------------------- + + ${UncivGame.Current.crashReportSysInfo?.getInfo().toString().prependIndentToOnlyNewLines(baseIndent)} + + -------------------------------- + **Message:** + ``` + ${message.prependIndentToOnlyNewLines(baseIndent)} + ``` + + **Save Mods:** + ``` + ${tryGetSaveMods().prependIndentToOnlyNewLines(baseIndent)} + ``` + + **Save Data:** +
Show Saved Game + + ``` + ${tryGetSaveGame().prependIndentToOnlyNewLines(baseIndent)} + ``` +
""".trimIndent() } diff --git a/core/src/com/unciv/MainMenuScreen.kt b/core/src/com/unciv/MainMenuScreen.kt index bbfca4afeb..8c0d81ff8b 100644 --- a/core/src/com/unciv/MainMenuScreen.kt +++ b/core/src/com/unciv/MainMenuScreen.kt @@ -68,10 +68,10 @@ class MainMenuScreen: BaseScreen() { // will not exist unless we reset the ruleset and images ImageGetter.ruleset = RulesetCache.getBaseRuleset() - thread(name = "ShowMapBackground") { + crashHandlingThread(name = "ShowMapBackground") { val newMap = MapGenerator(RulesetCache.getBaseRuleset()) .generateMap(MapParameters().apply { mapSize = MapSizeNew(MapSize.Small); type = MapType.default }) - Gdx.app.postRunnable { // for GL context + postCrashHandlingRunnable { // for GL context ImageGetter.setNewRuleset(RulesetCache.getBaseRuleset()) val mapHolder = EditorMapHolder(MapEditorScreen(), newMap) backgroundTable.addAction(Actions.sequence( @@ -197,10 +197,10 @@ class MainMenuScreen: BaseScreen() { val loadingPopup = Popup(this) loadingPopup.addGoodSizedLabel("Loading...") loadingPopup.open() - thread { + crashHandlingThread { // Load game from file to class on separate thread to avoid ANR... fun outOfMemory() { - Gdx.app.postRunnable { + postCrashHandlingRunnable { loadingPopup.close() ToastPopup("Not enough memory on phone to load game!", this) } @@ -211,7 +211,7 @@ class MainMenuScreen: BaseScreen() { savedGame = GameSaver.loadGameByName(autosave) } catch (oom: OutOfMemoryError) { outOfMemory() - return@thread + return@crashHandlingThread } 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 { @@ -221,17 +221,17 @@ class MainMenuScreen: BaseScreen() { GameSaver.loadGameFromFile(autosaves.maxByOrNull { it.lastModified() }!!) } catch (oom: OutOfMemoryError) { // The autosave could have oom problems as well... smh outOfMemory() - return@thread + return@crashHandlingThread } catch (ex: Exception) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { loadingPopup.close() ToastPopup("Cannot resume game!", this) } - return@thread + return@crashHandlingThread } } - Gdx.app.postRunnable { /// ... and load it into the screen on main thread for GL context + postCrashHandlingRunnable { /// ... and load it into the screen on main thread for GL context try { game.loadGame(savedGame) dispose() @@ -245,18 +245,18 @@ class MainMenuScreen: BaseScreen() { private fun quickstartNewGame() { ToastPopup("Working...", this) val errorText = "Cannot start game with the default new game parameters!" - thread { + crashHandlingThread { val newGame: GameInfo // Can fail when starting the game... try { newGame = GameStarter.startNewGame(GameSetupInfo.fromSettings("Chieftain")) } catch (ex: Exception) { - Gdx.app.postRunnable { ToastPopup(errorText, this) } - return@thread + postCrashHandlingRunnable { ToastPopup(errorText, this) } + return@crashHandlingThread } // ...or when loading the game - Gdx.app.postRunnable { + postCrashHandlingRunnable { try { game.loadGame(newGame) } catch (outOfMemory: OutOfMemoryError) { diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 3630d13f8c..8302f38a15 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -20,8 +20,6 @@ import com.unciv.ui.utils.* import com.unciv.ui.worldscreen.PlayerReadyScreen import com.unciv.ui.worldscreen.WorldScreen import java.util.* -import kotlin.concurrent.thread - class UncivGame(parameters: UncivGameParameters) : Game() { @@ -29,7 +27,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { constructor(version: String) : this(UncivGameParameters(version, null)) val version = parameters.version - private val crashReportSender = parameters.crashReportSender + val crashReportSysInfo = parameters.crashReportSysInfo val cancelDiscordEvent = parameters.cancelDiscordEvent val fontImplementation = parameters.fontImplementation val consoleMode = parameters.consoleMode @@ -39,7 +37,6 @@ class UncivGame(parameters: UncivGameParameters) : Game() { lateinit var gameInfo: GameInfo fun isGameInfoInitialized() = this::gameInfo.isInitialized lateinit var settings: GameSettings - lateinit var crashController: CrashController lateinit var musicController: MusicController /** @@ -105,7 +102,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { Gdx.graphics.isContinuousRendering = settings.continuousRendering - thread(name = "LoadJSON") { + crashHandlingThread(name = "LoadJSON") { RulesetCache.loadRulesets(printOutput = true) translations.tryReadTranslationForCurrentLanguage() translations.loadPercentageCompleteOfLanguages() @@ -117,7 +114,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { } // This stuff needs to run on the main thread because it needs the GL context - Gdx.app.postRunnable { + postCrashHandlingRunnable { musicController.chooseTrack(suffix = MusicMood.Menu) ImageGetter.ruleset = RulesetCache.getBaseRuleset() // so that we can enter the map editor without having to load a game first @@ -128,7 +125,6 @@ class UncivGame(parameters: UncivGameParameters) : Game() { isInitialized = true } } - crashController = CrashController.Impl(crashReportSender) } fun loadGame(gameInfo: GameInfo) { diff --git a/core/src/com/unciv/UncivGameParameters.kt b/core/src/com/unciv/UncivGameParameters.kt index 9ee7153b36..3b0d76b2a2 100644 --- a/core/src/com/unciv/UncivGameParameters.kt +++ b/core/src/com/unciv/UncivGameParameters.kt @@ -1,12 +1,12 @@ package com.unciv import com.unciv.logic.CustomSaveLocationHelper -import com.unciv.ui.utils.CrashReportSender +import com.unciv.ui.utils.CrashReportSysInfo import com.unciv.ui.utils.LimitOrientationsHelper import com.unciv.ui.utils.NativeFontImplementation class UncivGameParameters(val version: String, - val crashReportSender: CrashReportSender? = null, + val crashReportSysInfo: CrashReportSysInfo? = null, val cancelDiscordEvent: (() -> Unit)? = null, val fontImplementation: NativeFontImplementation? = null, val consoleMode: Boolean = false, diff --git a/core/src/com/unciv/logic/GameSaver.kt b/core/src/com/unciv/logic/GameSaver.kt index f15d9c26b0..84a688cb1d 100644 --- a/core/src/com/unciv/logic/GameSaver.kt +++ b/core/src/com/unciv/logic/GameSaver.kt @@ -5,6 +5,8 @@ import com.badlogic.gdx.files.FileHandle import com.badlogic.gdx.utils.Json import com.unciv.UncivGame import com.unciv.models.metadata.GameSettings +import com.unciv.ui.utils.crashHandlingThread +import com.unciv.ui.utils.postCrashHandlingRunnable import java.io.File import kotlin.concurrent.thread @@ -144,10 +146,10 @@ object GameSaver { // On the other hand if we alter the game data while it's being serialized we could get a concurrent modification exception. // So what we do is we clone all the game data and serialize the clone. val gameInfoClone = gameInfo.clone() - thread(name = "Autosave") { + crashHandlingThread(name = "Autosave") { autoSaveSingleThreaded(gameInfoClone) // do this on main thread - Gdx.app.postRunnable ( postRunnable ) + postCrashHandlingRunnable ( postRunnable ) } } diff --git a/core/src/com/unciv/models/CrashReport.kt b/core/src/com/unciv/models/CrashReport.kt deleted file mode 100644 index becddcec96..0000000000 --- a/core/src/com/unciv/models/CrashReport.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.unciv.models - -data class CrashReport( - val gameInfo: String, - val mods: LinkedHashSet, - val version: String -) \ No newline at end of file diff --git a/core/src/com/unciv/models/simulation/Simulation.kt b/core/src/com/unciv/models/simulation/Simulation.kt index 71d97854d9..348356d2e9 100644 --- a/core/src/com/unciv/models/simulation/Simulation.kt +++ b/core/src/com/unciv/models/simulation/Simulation.kt @@ -4,8 +4,8 @@ import com.unciv.logic.GameInfo import com.unciv.logic.GameStarter import com.unciv.models.ruleset.VictoryType import com.unciv.models.metadata.GameSetupInfo +import com.unciv.ui.utils.crashHandlingThread import kotlin.time.Duration -import kotlin.concurrent.thread import kotlin.math.max import kotlin.time.ExperimentalTime @@ -44,7 +44,7 @@ class Simulation( startTime = System.currentTimeMillis() val threads: ArrayList = ArrayList() for (threadId in 1..threadsNumber) { - threads.add(thread { + threads.add(crashHandlingThread { for (i in 1..simulationsPerThread) { val gameInfo = GameStarter.startNewGame(GameSetupInfo(newGameInfo)) gameInfo.simulateMaxTurns = maxTurns diff --git a/core/src/com/unciv/ui/audio/MusicTrackController.kt b/core/src/com/unciv/ui/audio/MusicTrackController.kt index 4cb5f23d40..d14b507957 100644 --- a/core/src/com/unciv/ui/audio/MusicTrackController.kt +++ b/core/src/com/unciv/ui/audio/MusicTrackController.kt @@ -3,6 +3,7 @@ package com.unciv.ui.audio import com.badlogic.gdx.Gdx import com.badlogic.gdx.audio.Music import com.badlogic.gdx.files.FileHandle +import com.unciv.ui.utils.crashHandlingThread import kotlin.concurrent.thread /** Wraps one Gdx Music instance and manages threaded loading, playback, fading and cleanup */ @@ -57,7 +58,7 @@ class MusicTrackController(private var volume: Float) { ) { if (state != State.None || loaderThread != null || music != null) throw IllegalStateException("MusicTrackController.load should only be called once") - loaderThread = thread(name = "MusicLoader") { + loaderThread = crashHandlingThread(name = "MusicLoader") { state = State.Loading try { music = Gdx.audio.newMusic(file) diff --git a/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt b/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt index e0f9b02424..a6c31673e1 100644 --- a/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt +++ b/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt @@ -203,10 +203,10 @@ class CityConstructionsTable(private val cityScreen: CityScreen) { availableConstructionsTable.add("Loading...".toLabel()).pad(10f) } - thread(name = "Construction info gathering - ${cityScreen.city.name}") { + crashHandlingThread(name = "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() - Gdx.app.postRunnable { + postCrashHandlingRunnable { val units = ArrayList() val buildableWonders = ArrayList
() val buildableNationalWonders = ArrayList
() diff --git a/core/src/com/unciv/ui/mapeditor/MapEditorScreen.kt b/core/src/com/unciv/ui/mapeditor/MapEditorScreen.kt index 6b7a2438bc..2361828e1e 100644 --- a/core/src/com/unciv/ui/mapeditor/MapEditorScreen.kt +++ b/core/src/com/unciv/ui/mapeditor/MapEditorScreen.kt @@ -147,7 +147,7 @@ class MapEditorScreen(): BaseScreen() { tileMap.mapParameters.run { val areaFromSize = getArea() if (areaFromSize == areaFromTiles) return - Gdx.app.postRunnable { + postCrashHandlingRunnable { val message = ("Invalid map: Area ([$areaFromTiles]) does not match saved dimensions ([" + displayMapDimensions() + "]).").tr() + "\n" + "The dimensions have now been fixed for you.".tr() diff --git a/core/src/com/unciv/ui/mapeditor/NewMapScreen.kt b/core/src/com/unciv/ui/mapeditor/NewMapScreen.kt index 63dd867b6a..dbd6f7b433 100644 --- a/core/src/com/unciv/ui/mapeditor/NewMapScreen.kt +++ b/core/src/com/unciv/ui/mapeditor/NewMapScreen.kt @@ -83,7 +83,7 @@ class NewMapScreen(val mapParameters: MapParameters = getDefaultParameters()) : rightSideButton.onClick { val message = mapParameters.mapSize.fixUndesiredSizes(mapParameters.worldWrap) if (message != null) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { ToastPopup( message, UncivGame.Current.screen as BaseScreen, 4000 ) with (mapParameters.mapSize) { mapParametersTable.customMapSizeRadius.text = radius.toString() @@ -96,12 +96,12 @@ class NewMapScreen(val mapParameters: MapParameters = getDefaultParameters()) : Gdx.input.inputProcessor = null // remove input processing - nothing will be clicked! rightButtonSetEnabled(false) - thread(name = "MapGenerator") { + crashHandlingThread(name = "MapGenerator") { try { // Map generation can take a while and we don't want ANRs generatedMap = MapGenerator(ruleset).generateMap(mapParameters) - Gdx.app.postRunnable { + postCrashHandlingRunnable { saveDefaultParameters(mapParameters) val mapEditorScreen = MapEditorScreen(generatedMap!!) mapEditorScreen.ruleset = ruleset @@ -110,7 +110,7 @@ class NewMapScreen(val mapParameters: MapParameters = getDefaultParameters()) : } catch (exception: Exception) { println("Map generator exception: ${exception.message}") - Gdx.app.postRunnable { + postCrashHandlingRunnable { rightButtonSetEnabled(true) val cantMakeThatMapPopup = Popup(this) cantMakeThatMapPopup.addGoodSizedLabel("It looks like we can't make a map with the parameters you requested!".tr()) diff --git a/core/src/com/unciv/ui/mapeditor/SaveAndLoadMapScreen.kt b/core/src/com/unciv/ui/mapeditor/SaveAndLoadMapScreen.kt index 8cec42b953..b94ba4d8fd 100644 --- a/core/src/com/unciv/ui/mapeditor/SaveAndLoadMapScreen.kt +++ b/core/src/com/unciv/ui/mapeditor/SaveAndLoadMapScreen.kt @@ -32,17 +32,17 @@ class SaveAndLoadMapScreen(mapToSave: TileMap?, save:Boolean = false, previousSc rightSideButtonAction = { mapToSave!!.mapParameters.name = mapNameTextField.text mapToSave.mapParameters.type = MapType.custom - thread(name = "SaveMap") { + crashHandlingThread(name = "SaveMap") { try { MapSaver.saveMap(mapNameTextField.text, getMapCloneForSave(mapToSave)) - Gdx.app.postRunnable { + postCrashHandlingRunnable { Gdx.input.inputProcessor = null // This is to stop ANRs happening here, until the map editor screen sets up. game.setScreen(MapEditorScreen(mapToSave)) dispose() } } catch (ex: Exception) { ex.printStackTrace() - Gdx.app.postRunnable { + postCrashHandlingRunnable { val cantLoadGamePopup = Popup(this) cantLoadGamePopup.addGoodSizedLabel("It looks like your map can't be saved!").row() cantLoadGamePopup.addCloseButton() @@ -54,11 +54,11 @@ class SaveAndLoadMapScreen(mapToSave: TileMap?, save:Boolean = false, previousSc } else { rightSideButton.setText("Load map".tr()) rightSideButtonAction = { - thread(name = "MapLoader") { + crashHandlingThread(name = "MapLoader") { var popup: Popup? = null var needPopup = true // loadMap can fail faster than postRunnable runs - Gdx.app.postRunnable { - if (!needPopup) return@postRunnable + postCrashHandlingRunnable { + if (!needPopup) return@postCrashHandlingRunnable popup = Popup(this).apply { addGoodSizedLabel("Loading...") open() @@ -76,12 +76,12 @@ class SaveAndLoadMapScreen(mapToSave: TileMap?, save:Boolean = false, previousSc if (map.mapParameters.baseRuleset !in RulesetCache) missingMods += map.mapParameters.baseRuleset if (missingMods.isNotEmpty()) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { needPopup = false popup?.close() ToastPopup("Missing mods: [${missingMods.joinToString()}]", this) } - } else Gdx.app.postRunnable { + } else postCrashHandlingRunnable { Gdx.input.inputProcessor = null // This is to stop ANRs happening here, until the map editor screen sets up. try { // For deprecated maps, set the base ruleset field if it's still saved in the mods field @@ -105,7 +105,7 @@ class SaveAndLoadMapScreen(mapToSave: TileMap?, save:Boolean = false, previousSc } catch (ex: Throwable) { needPopup = false ex.printStackTrace() - Gdx.app.postRunnable { + postCrashHandlingRunnable { popup?.close() println("Error loading map \"$chosenMap\": ${ex.localizedMessage}") ToastPopup("Error loading map!".tr() + diff --git a/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt b/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt index 5f5a85e249..dd83951877 100644 --- a/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt +++ b/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt @@ -80,7 +80,7 @@ class EditMultiplayerGameInfoScreen(val gameInfo: GameInfoPreview?, gameName: St popup.addGoodSizedLabel("Working...").row() popup.open() - thread { + crashHandlingThread { try { //download to work with newest game state val gameInfo = OnlineMultiplayer().tryDownloadGame(gameId) @@ -107,14 +107,14 @@ class EditMultiplayerGameInfoScreen(val gameInfo: GameInfoPreview?, gameName: St GameSaver.saveGame(updatedSave, gameName) OnlineMultiplayer().tryUploadGame(gameInfo, withPreview = true) - Gdx.app.postRunnable { + postCrashHandlingRunnable { popup.close() //go back to the MultiplayerScreen backScreen.game.setScreen(backScreen) backScreen.reloadGameListUI() } } else { - Gdx.app.postRunnable { + postCrashHandlingRunnable { //change popup text popup.innerTable.clear() popup.addGoodSizedLabel("You can only resign if it's your turn").row() @@ -122,7 +122,7 @@ class EditMultiplayerGameInfoScreen(val gameInfo: GameInfoPreview?, gameName: St } } } catch (ex: Exception) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { //change popup text popup.innerTable.clear() popup.addGoodSizedLabel("Could not upload game!").row() @@ -131,4 +131,4 @@ class EditMultiplayerGameInfoScreen(val gameInfo: GameInfoPreview?, gameName: St } } } -} \ 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 8e4e72673f..927c68e334 100644 --- a/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt +++ b/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt @@ -141,7 +141,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { addGameButton.setText("Working...".tr()) addGameButton.disable() - thread(name = "MultiplayerDownload") { + crashHandlingThread(name = "MultiplayerDownload") { try { // The tryDownload can take more than 500ms. Therefore, to avoid ANRs, // we need to run it in a different thread. @@ -151,7 +151,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { else GameSaver.saveGame(gamePreview, gameName) - Gdx.app.postRunnable { reloadGameListUI() } + postCrashHandlingRunnable { reloadGameListUI() } } catch (ex: FileNotFoundException) { // Game is so old that a preview could not be found on dropbox lets try the real gameInfo instead try { @@ -161,9 +161,9 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { else GameSaver.saveGame(gamePreview, gameName) - Gdx.app.postRunnable { reloadGameListUI() } + postCrashHandlingRunnable { reloadGameListUI() } } catch (ex: Exception) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { val errorPopup = Popup(this) errorPopup.addGoodSizedLabel("Could not download game!") errorPopup.row() @@ -172,7 +172,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { } } } catch (ex: Exception) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { val errorPopup = Popup(this) errorPopup.addGoodSizedLabel("Could not download game!") errorPopup.row() @@ -180,7 +180,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { errorPopup.open() } } - Gdx.app.postRunnable { + postCrashHandlingRunnable { addGameButton.setText(addGameText) addGameButton.enable() } @@ -197,7 +197,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { // For whatever reason, the only way to show the popup before the ANRs started was to // call the loadGame explicitly with a runnable on the main thread. // Maybe this adds just enough lag for the popup to show up - Gdx.app.postRunnable { + postCrashHandlingRunnable { game.loadGame(OnlineMultiplayer().tryDownloadGame((multiplayerGames[selectedGameFile]!!.gameId))) } } catch (ex: Exception) { @@ -279,7 +279,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { continue } - thread(name = "loadGameFile") { + crashHandlingThread(name = "loadGameFile") { try { val game = gameSaver.loadGamePreviewFromFile(gameSaveFile) @@ -288,7 +288,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { multiplayerGames[gameSaveFile] = game } - Gdx.app.postRunnable { + postCrashHandlingRunnable { turnIndicator.clear() if (isUsersTurn(game)) { turnIndicator.add(ImageGetter.getImage("OtherIcons/ExclamationMark")).size(50f) @@ -299,7 +299,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { } } catch (usx: UncivShowableException) { //Gets thrown when mods are not installed - Gdx.app.postRunnable { + postCrashHandlingRunnable { val popup = Popup(this) popup.addGoodSizedLabel(usx.message!! + " in ${gameSaveFile.name()}").row() popup.addCloseButton() @@ -309,7 +309,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { turnIndicator.add(ImageGetter.getImage("StatIcons/Malcontent")).size(50f) } } catch (ex: Exception) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { ToastPopup("Could not refresh!", this) turnIndicator.clear() turnIndicator.add(ImageGetter.getImage("StatIcons/Malcontent")).size(50f) @@ -330,7 +330,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { refreshButton.disable() //One thread for all downloads - thread(name = "multiplayerGameDownload") { + crashHandlingThread(name = "multiplayerGameDownload") { for ((fileHandle, gameInfo) in multiplayerGames) { try { // Update game without overriding multiplayer settings @@ -347,21 +347,21 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { multiplayerGames[fileHandle] = game } catch (ex: Exception) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { ToastPopup("Could not download game!" + " ${fileHandle.name()}", this) } } } catch (ex: Exception) { //skipping one is not fatal //Trying to use as many prev. used strings as possible - Gdx.app.postRunnable { + postCrashHandlingRunnable { ToastPopup("Could not download game!" + " ${fileHandle.name()}", this) } } } //Reset UI - Gdx.app.postRunnable { + postCrashHandlingRunnable { addGameButton.enable() refreshButton.setText(refreshText) refreshButton.enable() @@ -418,4 +418,4 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { } } -} \ No newline at end of file +} diff --git a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt index d146bd8320..85fcf85814 100644 --- a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt @@ -109,7 +109,7 @@ class NewGameScreen( val mapSize = gameSetupInfo.mapParameters.mapSize val message = mapSize.fixUndesiredSizes(gameSetupInfo.mapParameters.worldWrap) if (message != null) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { ToastPopup( message, UncivGame.Current.screen as BaseScreen, 4000 ) with (mapOptionsTable.generatedMapOptionsTable) { customMapSizeRadius.text = mapSize.radius.toString() @@ -125,7 +125,7 @@ class NewGameScreen( rightSideButton.disable() rightSideButton.setText("Working...".tr()) - thread(name = "NewGame") { + crashHandlingThread(name = "NewGame") { // Creating a new game can take a while and we don't want ANRs newGameThread() } @@ -181,7 +181,7 @@ class NewGameScreen( newGame = GameStarter.startNewGame(gameSetupInfo) } catch (exception: Exception) { exception.printStackTrace() - Gdx.app.postRunnable { + postCrashHandlingRunnable { Popup(this).apply { addGoodSizedLabel("It looks like we can't make a map with the parameters you requested!".tr()).row() addGoodSizedLabel("Maybe you put too many players into too small a map?".tr()).row() @@ -202,7 +202,7 @@ class NewGameScreen( // Save gameId to clipboard because you have to do it anyway. Gdx.app.clipboard.contents = newGame!!.gameId // Popup to notify the User that the gameID got copied to the clipboard - Gdx.app.postRunnable { ToastPopup("gameID copied to clipboard".tr(), game.worldScreen, 2500) } + postCrashHandlingRunnable { ToastPopup("gameID copied to clipboard".tr(), game.worldScreen, 2500) } GameSaver.autoSave(newGame!!) {} @@ -210,7 +210,7 @@ class NewGameScreen( val newGamePreview = newGame!!.asPreview() GameSaver.saveGame(newGamePreview, newGamePreview.gameId) } catch (ex: Exception) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { Popup(this).apply { addGoodSizedLabel("Could not upload game!") addCloseButton() diff --git a/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt b/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt index 157827e625..9950ed6c49 100644 --- a/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt +++ b/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt @@ -194,19 +194,19 @@ class ModManagementScreen( * calls itself for the next page of search results */ private fun tryDownloadPage(pageNum: Int) { - runningSearchThread = thread(name="GitHubSearch") { + runningSearchThread = crashHandlingThread(name="GitHubSearch") { val repoSearch: Github.RepoSearch try { repoSearch = Github.tryGetGithubReposWithTopic(amountPerPage, pageNum)!! } catch (ex: Exception) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { ToastPopup("Could not download mod list", this) } runningSearchThread = null - return@thread + return@crashHandlingThread } - Gdx.app.postRunnable { + postCrashHandlingRunnable { // clear and remove last cell if it is the "..." indicator val lastCell = downloadTable.cells.lastOrNull() if (lastCell != null && lastCell.actor is Label && (lastCell.actor as Label).text.toString() == "...") { @@ -215,7 +215,7 @@ class ModManagementScreen( } for (repo in repoSearch.items) { - if (stopBackgroundTasks) return@postRunnable + if (stopBackgroundTasks) return@postCrashHandlingRunnable repo.name = repo.name.replace('-', ' ') // Mods we have manually decided to remove for instability are removed here @@ -404,13 +404,13 @@ 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 = {}) { - thread(name="DownloadMod") { // to avoid ANRs - we've learnt our lesson from previous download-related actions + crashHandlingThread(name="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! rewriteModOptions(repo, modFolder) - Gdx.app.postRunnable { + postCrashHandlingRunnable { ToastPopup("[${repo.name}] Downloaded!", this) RulesetCache.loadRulesets() RulesetCache[repo.name]?.let { @@ -421,7 +421,7 @@ class ModManagementScreen( unMarkUpdatedMod(repo.name) } } catch (ex: Exception) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { ToastPopup("Could not download [${repo.name}]", this) } } finally { diff --git a/core/src/com/unciv/ui/pickerscreens/TechPickerScreen.kt b/core/src/com/unciv/ui/pickerscreens/TechPickerScreen.kt index 2f4c62ceaf..54c1eadcf4 100644 --- a/core/src/com/unciv/ui/pickerscreens/TechPickerScreen.kt +++ b/core/src/com/unciv/ui/pickerscreens/TechPickerScreen.kt @@ -288,7 +288,7 @@ class TechPickerScreen( } private fun centerOnTechnology(tech: Technology) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { techNameToButton[tech.name]?.let { scrollPane.scrollTo(it.x, it.y, it.width, it.height, true, true) scrollPane.updateVisualScroll() @@ -307,4 +307,4 @@ class TechPickerScreen( } } -} \ No newline at end of file +} diff --git a/core/src/com/unciv/ui/saves/LoadGameScreen.kt b/core/src/com/unciv/ui/saves/LoadGameScreen.kt index 26f3ab2762..7e5296cdb9 100644 --- a/core/src/com/unciv/ui/saves/LoadGameScreen.kt +++ b/core/src/com/unciv/ui/saves/LoadGameScreen.kt @@ -41,13 +41,13 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t val loadingPopup = Popup( this) loadingPopup.addGoodSizedLabel("Loading...") loadingPopup.open() - thread { + crashHandlingThread { 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) - Gdx.app.postRunnable { UncivGame.Current.loadGame(loadedGame) } + postCrashHandlingRunnable { UncivGame.Current.loadGame(loadedGame) } } catch (ex: Exception) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { loadingPopup.close() val cantLoadGamePopup = Popup(this) cantLoadGamePopup.addGoodSizedLabel("It looks like your saved game can't be loaded!").row() @@ -99,7 +99,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t loadFromCustomLocation.onClick { GameSaver.loadGameFromCustomLocation { gameInfo, exception -> if (gameInfo != null) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { game.loadGame(gameInfo) } } else if (exception !is CancellationException) { @@ -153,12 +153,12 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t loadImage.addAction(Actions.rotateBy(360f, 2f)) saveTable.add(loadImage).size(50f) - thread { // Apparently, even jut getting the list of saves can cause ANRs - + 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 // .toList() because otherwise the lastModified will only be checked inside the postRunnable val saves = GameSaver.getSaves().sortedByDescending { it.lastModified() }.toList() - Gdx.app.postRunnable { + postCrashHandlingRunnable { saveTable.clear() for (save in saves) { if (save.name().startsWith("Autosave") && !showAutosaves) continue @@ -183,7 +183,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t val savedAt = Date(save.lastModified()) var textToSet = save.name() + "\n${"Saved at".tr()}: " + savedAt.formatDate() - thread { // Even loading the game to get its metadata can take a long time on older phones + crashHandlingThread { // 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() } @@ -196,7 +196,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t textToSet += "\n${"Could not load game".tr()}!" } - Gdx.app.postRunnable { + postCrashHandlingRunnable { descriptionLabel.setText(textToSet) } } diff --git a/core/src/com/unciv/ui/saves/SaveGameScreen.kt b/core/src/com/unciv/ui/saves/SaveGameScreen.kt index 3b0d8c1f84..64a6452521 100644 --- a/core/src/com/unciv/ui/saves/SaveGameScreen.kt +++ b/core/src/com/unciv/ui/saves/SaveGameScreen.kt @@ -56,10 +56,10 @@ class SaveGameScreen(val gameInfo: GameInfo) : PickerScreen(disableScroll = true errorLabel.setText("") saveToCustomLocation.setText("Saving...".tr()) saveToCustomLocation.disable() - thread(name = "SaveGame") { + crashHandlingThread(name = "SaveGame") { GameSaver.saveGameToCustomLocation(gameInfo, gameNameTextField.text) { e -> if (e == null) { - Gdx.app.postRunnable { game.setWorldScreen() } + postCrashHandlingRunnable { game.setWorldScreen() } } else if (e !is CancellationException) { errorLabel.setText("Could not save game to custom location!".tr()) e.printStackTrace() @@ -94,9 +94,9 @@ class SaveGameScreen(val gameInfo: GameInfo) : PickerScreen(disableScroll = true private fun saveGame() { rightSideButton.setText("Saving...".tr()) - thread(name = "SaveGame") { + crashHandlingThread(name = "SaveGame") { GameSaver.saveGame(gameInfo, gameNameTextField.text) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { if (it != null) ToastPopup("Could not save game!", this) 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 5fc7ad24a7..a682c58a7b 100644 --- a/core/src/com/unciv/ui/utils/BaseScreen.kt +++ b/core/src/com/unciv/ui/utils/BaseScreen.kt @@ -17,7 +17,6 @@ import com.unciv.models.Tutorial import com.unciv.ui.tutorials.TutorialController import com.unciv.ui.worldscreen.WorldScreen import com.unciv.ui.worldscreen.mainmenu.OptionsPopup -import kotlin.concurrent.thread open class BaseScreen : Screen { @@ -127,12 +126,12 @@ open class BaseScreen : Screen { throw IllegalArgumentException("openOptionsPopup called on wrong derivative class") } limitOrientationsHelper.allowPortrait(false) - thread(name="WaitForRotation") { + crashHandlingThread(name="WaitForRotation") { var waited = 0 while (true) { val newScreen = (UncivGame.Current.screen as? BaseScreen) if (waited >= 10000 || newScreen!=null && !newScreen.isPortrait() ) { - Gdx.app.postRunnable { OptionsPopup(newScreen ?: this).open(true) } + postCrashHandlingRunnable { OptionsPopup(newScreen ?: this).open(true) } break } Thread.sleep(200) diff --git a/core/src/com/unciv/ui/utils/CrashController.kt b/core/src/com/unciv/ui/utils/CrashController.kt deleted file mode 100644 index d85568912d..0000000000 --- a/core/src/com/unciv/ui/utils/CrashController.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.unciv.ui.utils - -import com.badlogic.gdx.utils.Json -import com.unciv.UncivGame -import com.unciv.models.CrashReport -import com.unciv.ui.saves.Gzip - -interface CrashController { - - fun crashOccurred() - fun showDialogIfNeeded() - - class Impl(private val crashReportSender: CrashReportSender?) : CrashController { - - companion object { - private const val MESSAGE = "Oh no! It looks like something went DISASTROUSLY wrong!" + - " This is ABSOLUTELY not supposed to happen! Please send us an report" + - " and we'll try to fix it as fast as we can!" - private const val MESSAGE_FALLBACK = "Oh no! It looks like something went DISASTROUSLY wrong!" + - " This is ABSOLUTELY not supposed to happen! Please send me (yairm210@hotmail.com)" + - " an email with the game information (menu -> save game -> copy game info -> paste into email)" + - " and I'll try to fix it as fast as I can!" - } - - override fun crashOccurred() { - UncivGame.Current.settings.run { - hasCrashedRecently = true - save() - } - } - - override fun showDialogIfNeeded() { - UncivGame.Current.settings.run { - if (hasCrashedRecently) { - prepareDialog().open() - hasCrashedRecently = false - save() - } - } - } - - private fun prepareDialog(): Popup { - return if (crashReportSender == null) { - Popup(UncivGame.Current.worldScreen).apply { - addGoodSizedLabel(MESSAGE_FALLBACK).row() - addCloseButton() - } - } else { - Popup(UncivGame.Current.worldScreen).apply { - addGoodSizedLabel(MESSAGE).row() - addButton("Send report") { - crashReportSender.sendReport(buildReport()) - close() - } - addCloseButton() - } - } - } - - private fun buildReport(): CrashReport { - return UncivGame.Current.run { - val zippedGameInfo = Json().toJson(gameInfo).let { Gzip.zip(it) } - CrashReport( - zippedGameInfo, - LinkedHashSet(gameInfo.gameParameters.getModsAndBaseRuleset()), - version - ) - } - } - } -} diff --git a/core/src/com/unciv/ui/utils/CrashHandlingThread.kt b/core/src/com/unciv/ui/utils/CrashHandlingThread.kt new file mode 100644 index 0000000000..965d08ecc8 --- /dev/null +++ b/core/src/com/unciv/ui/utils/CrashHandlingThread.kt @@ -0,0 +1,26 @@ +package com.unciv.ui.utils + +import com.badlogic.gdx.Gdx +import kotlin.concurrent.thread + +/** Wrapped version of [kotlin.concurrent.thread], that brings the main game loop to a [com.unciv.CrashScreen] if an exception happens. */ +fun crashHandlingThread( + start: Boolean = true, + isDaemon: Boolean = false, + contextClassLoader: ClassLoader? = null, + name: String? = null, + priority: Int = -1, + block: () -> Unit +) = thread( + start = start, + isDaemon = isDaemon, + contextClassLoader = contextClassLoader, + name = name, + priority = priority, + block = block.wrapCrashHandlingUnit(true) + ) + +/** Wrapped version of Gdx.app.postRunnable ([com.badlogic.gdx.Application.postRunnable]), that brings the game loop to a [com.unciv.CrashScreen] if an exception occurs. */ +fun postCrashHandlingRunnable(runnable: () -> Unit) { + Gdx.app.postRunnable(runnable.wrapCrashHandlingUnit()) +} diff --git a/core/src/com/unciv/ui/utils/CrashReportSender.kt b/core/src/com/unciv/ui/utils/CrashReportSender.kt deleted file mode 100644 index 1c00aa9889..0000000000 --- a/core/src/com/unciv/ui/utils/CrashReportSender.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.unciv.ui.utils - -import com.unciv.models.CrashReport - -interface CrashReportSender { - - fun sendReport(report: CrashReport) -} \ No newline at end of file diff --git a/core/src/com/unciv/ui/utils/CrashReportSysInfo.kt b/core/src/com/unciv/ui/utils/CrashReportSysInfo.kt new file mode 100644 index 0000000000..d042ac72eb --- /dev/null +++ b/core/src/com/unciv/ui/utils/CrashReportSysInfo.kt @@ -0,0 +1,5 @@ +package com.unciv.ui.utils + +interface CrashReportSysInfo { + fun getInfo(): String +} diff --git a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt index 48eb95fee8..82ae91c3b9 100644 --- a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt +++ b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt @@ -12,7 +12,6 @@ import com.unciv.models.UncivSound import com.unciv.models.translations.tr import java.text.SimpleDateFormat import java.util.* -import kotlin.concurrent.thread import kotlin.random.Random /** @@ -53,7 +52,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) { - thread(name = "Sound") { Sounds.play(sound) } + crashHandlingThread(name = "Sound") { Sounds.play(sound) } function(event, x, y) } }) @@ -319,6 +318,10 @@ object UncivDateFormat { * * In case an exception or error is thrown, the return will be null. Therefore the return type is always nullable. * + * The game loop, threading, and event systems already use this to wrap nearly everything that can happen during the lifespan of the Unciv application. + * + * Therefore, it usually shouldn't be necessary to manually use this. See the note at the top of [CrashScreen].kt for details. + * * @param postToMainThread Whether the [CrashScreen] should be opened by posting a runnable to the main thread, instead of directly. Set this to true if the function is going to run on any thread other than the main loop. * @return Result from the function, or null if an exception is thrown. * */ @@ -343,12 +346,16 @@ fun (() -> R).wrapCrashHandling( /** * Returns a wrapped a version of a Unit-returning function which safely crashes the game to [CrashScreen] if an exception or error is thrown. * + * The game loop, threading, and event systems already use this to wrap nearly everything that can happen during the lifespan of the Unciv application. + * + * Therefore, it usually shouldn't be necessary to manually use this. See the note at the top of [CrashScreen].kt for details. + * * @param postToMainThread Whether the [CrashScreen] should be opened by posting a runnable to the main thread, instead of directly. Set this to true if the function is going to run on any thread other than the main loop. * */ fun (() -> Unit).wrapCrashHandlingUnit( postToMainThread: Boolean = false ): () -> Unit { val wrappedReturning = this.wrapCrashHandling(postToMainThread) - // Don't instantiate a new lambda every time. + // Don't instantiate a new lambda every time the return get called. return { wrappedReturning() ?: Unit } } diff --git a/core/src/com/unciv/ui/utils/Sounds.kt b/core/src/com/unciv/ui/utils/Sounds.kt index 0feb92d3fd..b6d63a0490 100644 --- a/core/src/com/unciv/ui/utils/Sounds.kt +++ b/core/src/com/unciv/ui/utils/Sounds.kt @@ -164,7 +164,7 @@ object Sounds { val initialDelay = if (isFresh && Gdx.app.type == Application.ApplicationType.Android) 40 else 0 if (initialDelay > 0 || resource.play(volume) == -1L) { - thread (name = "DelayedSound") { + crashHandlingThread(name = "DelayedSound") { Thread.sleep(initialDelay.toLong()) while (resource.play(volume) == -1L) { Thread.sleep(20L) diff --git a/core/src/com/unciv/ui/utils/ToastPopup.kt b/core/src/com/unciv/ui/utils/ToastPopup.kt index 64ef68f397..fbacf25bd6 100644 --- a/core/src/com/unciv/ui/utils/ToastPopup.kt +++ b/core/src/com/unciv/ui/utils/ToastPopup.kt @@ -21,9 +21,9 @@ class ToastPopup (message: String, screen: BaseScreen, val time: Long = 2000) : } private fun startTimer(){ - thread (name = "ResponsePopup") { + crashHandlingThread(name = "ResponsePopup") { Thread.sleep(time) - Gdx.app.postRunnable { this.close() } + postCrashHandlingRunnable { this.close() } } } diff --git a/core/src/com/unciv/ui/worldscreen/PlayerReadyScreen.kt b/core/src/com/unciv/ui/worldscreen/PlayerReadyScreen.kt index 31b03e41a9..7062ed6080 100644 --- a/core/src/com/unciv/ui/worldscreen/PlayerReadyScreen.kt +++ b/core/src/com/unciv/ui/worldscreen/PlayerReadyScreen.kt @@ -5,10 +5,7 @@ import com.badlogic.gdx.scenes.scene2d.Touchable import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.logic.GameInfo import com.unciv.logic.civilization.CivilizationInfo -import com.unciv.ui.utils.BaseScreen -import com.unciv.ui.utils.ImageGetter -import com.unciv.ui.utils.onClick -import com.unciv.ui.utils.toLabel +import com.unciv.ui.utils.* class PlayerReadyScreen(gameInfo: GameInfo, currentPlayerCiv: CivilizationInfo) : BaseScreen(){ init { @@ -19,7 +16,7 @@ class PlayerReadyScreen(gameInfo: GameInfo, currentPlayerCiv: CivilizationInfo) table.add("[$currentPlayerCiv] ready?".toLabel(currentPlayerCiv.nation.getInnerColor(),24)) table.onClick { - Gdx.app.postRunnable { // To avoid ANRs on Android when the creation of the worldscreen takes more than 500ms + postCrashHandlingRunnable { // To avoid ANRs on Android when the creation of the worldscreen takes more than 500ms game.worldScreen = WorldScreen(gameInfo, currentPlayerCiv) game.setWorldScreen() } @@ -27,4 +24,4 @@ class PlayerReadyScreen(gameInfo: GameInfo, currentPlayerCiv: CivilizationInfo) table.setFillParent(true) stage.addActor(table) } -} \ No newline at end of file +} diff --git a/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt b/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt index 497aefa8af..ed0c50420a 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt @@ -99,7 +99,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 - thread { + crashHandlingThread { val tile = tileGroup.tileInfo if (worldScreen.bottomUnitTable.selectedUnitIsSwapping) { @@ -107,7 +107,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@thread + return@crashHandlingThread } val attackableTile = BattleHelper.getAttackableEnemies(unit, unit.movement.getDistanceToTiles()) @@ -115,13 +115,13 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap if (unit.canAttack() && attackableTile != null) { Battle.moveAndAttack(MapUnitCombatant(unit), attackableTile) worldScreen.shouldUpdate = true - return@thread + return@crashHandlingThread } val canUnitReachTile = unit.movement.canReach(tile) if (canUnitReachTile) { moveUnitToTargetTile(listOf(unit), tile) - return@thread + return@crashHandlingThread } } } @@ -198,7 +198,7 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap val selectedUnit = selectedUnits.first() - thread(name = "TileToMoveTo") { + crashHandlingThread(name = "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 @@ -208,10 +208,10 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap } catch (ex: Exception) { println("Exception in getTileToMoveToThisTurn: ${ex.message}") ex.printStackTrace() - return@thread + return@crashHandlingThread } // can't move here - Gdx.app.postRunnable { + postCrashHandlingRunnable { try { // Because this is darned concurrent (as it MUST be to avoid ANRs), // there are edge cases where the canReach is true, @@ -254,7 +254,7 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap } private fun addTileOverlaysWithUnitMovement(selectedUnits: List, tileInfo: TileInfo) { - thread(name = "TurnsToGetThere") { + crashHandlingThread(name = "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, @@ -279,12 +279,12 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap unitToTurnsToTile[unit] = turnsToGetThere } - Gdx.app.postRunnable { + postCrashHandlingRunnable { val unitsWhoCanMoveThere = HashMap(unitToTurnsToTile.filter { it.value != 0 }) if (unitsWhoCanMoveThere.isEmpty()) { // give the regular tile overlays with no unit movement addTileOverlays(tileInfo) worldScreen.shouldUpdate = true - return@postRunnable + return@postCrashHandlingRunnable } val turnsToGetThere = unitsWhoCanMoveThere.values.maxOrNull()!! diff --git a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt index f52915d69e..4f379baf16 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt @@ -205,9 +205,9 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas // GameSaver.autoSave, SaveGameScreen.saveGame, LoadGameScreen.rightSideButton.onClick,... val quickSave = { val toast = ToastPopup("Quicksaving...", this) - thread(name = "SaveGame") { + crashHandlingThread(name = "SaveGame") { GameSaver.saveGame(gameInfo, "QuickSave") { - Gdx.app.postRunnable { + postCrashHandlingRunnable { toast.close() if (it != null) ToastPopup("Could not save game!", this) @@ -221,16 +221,16 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } val quickLoad = { val toast = ToastPopup("Quickloading...", this) - thread(name = "SaveGame") { + crashHandlingThread(name = "SaveGame") { try { val loadedGame = GameSaver.loadGameByName("QuickSave") - Gdx.app.postRunnable { + postCrashHandlingRunnable { toast.close() UncivGame.Current.loadGame(loadedGame) ToastPopup("Quickload successful.", this) } } catch (ex: Exception) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { ToastPopup("Could not load game!", this) } } @@ -328,7 +328,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas // 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) - Gdx.app.postRunnable { + postCrashHandlingRunnable { loadingGamePopup.add("Loading latest game state...".tr()) loadingGamePopup.open() } @@ -343,18 +343,18 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas && gameInfo.turns == latestGame.turns && latestGame.currentPlayer != gameInfo.getPlayerToViewAs().civName ) { - Gdx.app.postRunnable { loadingGamePopup.close() } + postCrashHandlingRunnable { loadingGamePopup.close() } shouldUpdate = true return } else { // if the game updated, even if it's not our turn, reload the world - // stuff has changed and the "waiting for X" will now show the correct civ stopMultiPlayerRefresher() latestGame.isUpToDate = true - Gdx.app.postRunnable { createNewWorldScreen(latestGame) } + postCrashHandlingRunnable { createNewWorldScreen(latestGame) } } } catch (ex: Throwable) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { val couldntDownloadLatestGame = Popup(this) couldntDownloadLatestGame.addGoodSizedLabel("Couldn't download the latest game state!").row() couldntDownloadLatestGame.addCloseButton() @@ -504,7 +504,6 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } private fun displayTutorialsOnUpdate() { - game.crashController.showDialogIfNeeded() displayTutorial(Tutorial.Introduction) @@ -632,34 +631,29 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas shouldUpdate = true - thread(name = "NextTurn") { // on a separate thread so the user can explore their world while we're passing the turn + crashHandlingThread(name = "NextTurn") { // on a separate thread so the user can explore their world while we're passing the turn if (consoleLog) println("\nNext turn starting " + Date().formatDate()) val startTime = System.currentTimeMillis() val gameInfoClone = gameInfo.clone() gameInfoClone.setTransients() // this can get expensive on large games, not the clone itself - try { - gameInfoClone.nextTurn() + gameInfoClone.nextTurn() - if (gameInfo.gameParameters.isOnlineMultiplayer) { - try { - OnlineMultiplayer().tryUploadGame(gameInfoClone, withPreview = true) - } catch (ex: Exception) { - Gdx.app.postRunnable { // Since we're changing the UI, that should be done on the main thread - val cantUploadNewGamePopup = Popup(this) - 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@thread + if (gameInfo.gameParameters.isOnlineMultiplayer) { + try { + OnlineMultiplayer().tryUploadGame(gameInfoClone, withPreview = true) + } catch (ex: Exception) { + postCrashHandlingRunnable { // Since we're changing the UI, that should be done on the main thread + val cantUploadNewGamePopup = Popup(this) + 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 } - } catch (ex: Exception) { - Gdx.app.postRunnable { game.crashController.crashOccurred() } - throw ex } game.gameInfo = gameInfoClone @@ -670,7 +664,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas // create a new WorldScreen to show the new stuff we've changed, and switch out the current screen. // do this on main thread - it's the only one that has a GL context to create images from - Gdx.app.postRunnable { + postCrashHandlingRunnable { if (gameInfoClone.currentPlayerCiv.civName != viewingCiv.civName @@ -798,10 +792,10 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas viewingCiv.hasMovedAutomatedUnits = true isPlayersTurn = false // Disable state changes nextTurnButton.disable() - thread(name="Move automated units") { + crashHandlingThread(name="Move automated units") { for (unit in viewingCiv.getCivUnits()) unit.doAction() - Gdx.app.postRunnable { + postCrashHandlingRunnable { shouldUpdate = true isPlayersTurn = true //Re-enable state changes nextTurnButton.enable() diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt index c3f32016ac..e3969c141a 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt @@ -273,7 +273,7 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { if (modCheckCheckBox == null) return modCheckCheckBox!!.disable() if (modCheckResultCell == null) return - thread(name="ModChecker") { + crashHandlingThread(name="ModChecker") { val lines = ArrayList() var noProblem = true for (mod in RulesetCache.values.sortedBy { it.name }) { @@ -297,7 +297,7 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { } if (noProblem) lines += FormattedLine("{No problems found}.",) - Gdx.app.postRunnable { + postCrashHandlingRunnable { // Don't just render text, since that will make all the conditionals in the mod replacement messages move to the end, which makes it unreadable // Don't use .toLabel() either, since that activates translations as well, which is what we're trying to avoid, // Instead, some manual work needs to be put in. @@ -504,7 +504,7 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { label.wrap = true add(label).padTop(20f).colspan(2).fillX().row() previousScreen.game.musicController.onChange { - Gdx.app.postRunnable { + postCrashHandlingRunnable { label.setText("Currently playing: [$it]".tr()) } } @@ -523,15 +523,15 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { errorTable.add("Downloading...".toLabel()) // So the whole game doesn't get stuck while downloading the file - thread(name = "Music") { + crashHandlingThread(name = "Music") { try { previousScreen.game.musicController.downloadDefaultFile() - Gdx.app.postRunnable { + postCrashHandlingRunnable { tabs.replacePage("Sound", getSoundTab()) previousScreen.game.musicController.chooseTrack(flags = MusicTrackChooserFlags.setPlayDefault) } } catch (ex: Exception) { - Gdx.app.postRunnable { + postCrashHandlingRunnable { errorTable.clear() errorTable.add("Could not download music!".toLabel(Color.RED)) } diff --git a/core/src/com/unciv/ui/worldscreen/unit/UnitActionsTable.kt b/core/src/com/unciv/ui/worldscreen/unit/UnitActionsTable.kt index 34beb074f2..02db8dfce6 100644 --- a/core/src/com/unciv/ui/worldscreen/unit/UnitActionsTable.kt +++ b/core/src/com/unciv/ui/worldscreen/unit/UnitActionsTable.kt @@ -44,7 +44,7 @@ class UnitActionsTable(val worldScreen: WorldScreen) : Table() { actionButton.onClick(unitAction.uncivSound, action) if (key != KeyCharAndCode.UNKNOWN) worldScreen.keyPressDispatcher[key] = { - thread(name = "Sound") { Sounds.play(unitAction.uncivSound) } + crashHandlingThread(name = "Sound") { Sounds.play(unitAction.uncivSound) } action() worldScreen.mapHolder.removeUnitActionOverlay() }