diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 2e5364ba8c..fd7fda5490 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -34,6 +34,17 @@ See your stats breakdown!\nEnter the Overview screen (top right corner) >\nClick 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: = +If this keeps happening, you can try disabling mods. = +You can also report this on the issue tracker. = +Copy = +Error report copied. = +Open Issue Tracker = +Please copy the error report first. = +Close Unciv = + # Buildings Unsellable = diff --git a/core/src/com/unciv/CrashHandlingStage.kt b/core/src/com/unciv/CrashHandlingStage.kt new file mode 100644 index 0000000000..57083d231d --- /dev/null +++ b/core/src/com/unciv/CrashHandlingStage.kt @@ -0,0 +1,125 @@ +package com.unciv + +import com.badlogic.gdx.graphics.g2d.Batch +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() + constructor(viewport: Viewport): super(viewport) + constructor(viewport: Viewport, batch: Batch) : super(viewport, batch) + + override fun draw() = { super.draw() }.wrapCrashHandlingUnit()() + override fun act() = { super.act() }.wrapCrashHandlingUnit()() + override fun act(delta: Float) = { super.act(delta) }.wrapCrashHandlingUnit()() + + override fun touchDown(screenX: Int, screenY: Int, pointer: Int, button: Int) + = { super.touchDown(screenX, screenY, pointer, button) }.wrapCrashHandling()() ?: true + override fun touchDragged(screenX: Int, screenY: Int, pointer: Int) + = { super.touchDragged(screenX, screenY, pointer) }.wrapCrashHandling()() ?: true + override fun touchUp(screenX: Int, screenY: Int, pointer: Int, button: Int) + = { super.touchUp(screenX, screenY, pointer, button) }.wrapCrashHandling()() ?: true + override fun mouseMoved(screenX: Int, screenY: Int) + = { super.mouseMoved(screenX, screenY) }.wrapCrashHandling()() ?: true + override fun scrolled(amountX: Float, amountY: Float) + = { super.scrolled(amountX, amountY) }.wrapCrashHandling()() ?: true + override fun keyDown(keyCode: Int) + = { super.keyDown(keyCode) }.wrapCrashHandling()() ?: true + override fun keyUp(keyCode: Int) + = { super.keyUp(keyCode) }.wrapCrashHandling()() ?: true + override fun keyTyped(character: Char) + = { super.keyTyped(character) }.wrapCrashHandling()() ?: true + +} + +// Example Stack traces from unhandled exceptions after a button click on Desktop and on Android are below. + +// 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{}. + +// 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. + + +// Button click (event): + +/* +Exception in thread "main" com.badlogic.gdx.utils.GdxRuntimeException: java.lang.Exception + at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.(Lwjgl3Application.java:122) + at com.unciv.app.desktop.DesktopLauncher.main(DesktopLauncher.kt:61) +Caused by: java.lang.Exception + at com.unciv.MainMenuScreen$newGameButton$1.invoke(MainMenuScreen.kt:107) + at com.unciv.MainMenuScreen$newGameButton$1.invoke(MainMenuScreen.kt:106) + at com.unciv.ui.utils.ExtensionFunctionsKt$onClick$1.invoke(ExtensionFunctions.kt:64) + at com.unciv.ui.utils.ExtensionFunctionsKt$onClick$1.invoke(ExtensionFunctions.kt:64) + at com.unciv.ui.utils.ExtensionFunctionsKt$onClickEvent$1.clicked(ExtensionFunctions.kt:57) + at com.badlogic.gdx.scenes.scene2d.utils.ClickListener.touchUp(ClickListener.java:88) + at com.badlogic.gdx.scenes.scene2d.InputListener.handle(InputListener.java:71) + at com.badlogic.gdx.scenes.scene2d.Stage.touchUp(Stage.java:355) + at com.badlogic.gdx.InputEventQueue.drain(InputEventQueue.java:70) + at com.badlogic.gdx.backends.lwjgl3.DefaultLwjgl3Input.update(DefaultLwjgl3Input.java:189) + at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Window.update(Lwjgl3Window.java:394) + at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.loop(Lwjgl3Application.java:143) + at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.(Lwjgl3Application.java:116) + ... 1 more + +E/AndroidRuntime: FATAL EXCEPTION: GLThread 299 + Process: com.unciv.app, PID: 5910 + java.lang.Exception + at com.unciv.MainMenuScreen$newGameButton$1.invoke(MainMenuScreen.kt:107) + at com.unciv.MainMenuScreen$newGameButton$1.invoke(MainMenuScreen.kt:106) + at com.unciv.ui.utils.ExtensionFunctionsKt$onClick$1.invoke(ExtensionFunctions.kt:64) + at com.unciv.ui.utils.ExtensionFunctionsKt$onClick$1.invoke(ExtensionFunctions.kt:64) + at com.unciv.ui.utils.ExtensionFunctionsKt$onClickEvent$1.clicked(ExtensionFunctions.kt:57) + at com.badlogic.gdx.scenes.scene2d.utils.ClickListener.touchUp(ClickListener.java:88) + at com.badlogic.gdx.scenes.scene2d.InputListener.handle(InputListener.java:71) + at com.badlogic.gdx.scenes.scene2d.Stage.touchUp(Stage.java:355) + at com.badlogic.gdx.backends.android.DefaultAndroidInput.processEvents(DefaultAndroidInput.java:425) + at com.badlogic.gdx.backends.android.AndroidGraphics.onDrawFrame(AndroidGraphics.java:469) + at android.opengl.GLSurfaceView$GLThread.guardedRun(GLSurfaceView.java:1522) + at android.opengl.GLSurfaceView$GLThread.run(GLSurfaceView.java:1239) + */ + +// Invalid Natural Wonder (rendering): + +/* +Exception in thread "main" java.lang.NullPointerException + at com.unciv.logic.map.TileInfo.getNaturalWonder(TileInfo.kt:149) + at com.unciv.logic.map.TileInfo.getTileStats(TileInfo.kt:255) + at com.unciv.logic.map.TileInfo.getTileStats(TileInfo.kt:240) + at com.unciv.ui.worldscreen.bottombar.TileInfoTable.getStatsTable(TileInfoTable.kt:43) + at com.unciv.ui.worldscreen.bottombar.TileInfoTable.updateTileTable$core(TileInfoTable.kt:25) + at com.unciv.ui.worldscreen.WorldScreen.update(WorldScreen.kt:383) + at com.unciv.ui.worldscreen.WorldScreen.render(WorldScreen.kt:828) + at com.badlogic.gdx.Game.render(Game.java:46) + at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Window.update(Lwjgl3Window.java:403) + at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.loop(Lwjgl3Application.java:143) + at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.(Lwjgl3Application.java:116) + at com.unciv.app.desktop.DesktopLauncher.main(DesktopLauncher.kt:61) + */ + +// Thread: + +/* +Exception in thread "Thread-5" java.lang.Exception + at com.unciv.MainMenuScreen$newGameButton$1$1.invoke(MainMenuScreen.kt:107) + at com.unciv.MainMenuScreen$newGameButton$1$1.invoke(MainMenuScreen.kt:107) + at kotlin.concurrent.ThreadsKt$thread$thread$1.run(Thread.kt:30) + */ + +// Gdx.app.postRunnable: + +/* +Exception in thread "main" com.badlogic.gdx.utils.GdxRuntimeException: java.lang.Exception + at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.(Lwjgl3Application.java:122) + at com.unciv.app.desktop.DesktopLauncher.main(DesktopLauncher.kt:61) +Caused by: java.lang.Exception + at com.unciv.MainMenuScreen$loadGameTable$1.invoke$lambda-0(MainMenuScreen.kt:112) + at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.loop(Lwjgl3Application.java:159) + at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.(Lwjgl3Application.java:116) + ... 1 more + */ diff --git a/core/src/com/unciv/CrashScreen.kt b/core/src/com/unciv/CrashScreen.kt new file mode 100644 index 0000000000..9c915fddd0 --- /dev/null +++ b/core/src/com/unciv/CrashScreen.kt @@ -0,0 +1,140 @@ +package com.unciv + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.graphics.Color +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.unciv.models.ruleset.RulesetCache +import com.unciv.ui.utils.* +import java.io.PrintWriter +import java.io.StringWriter + +/** Screen to crash to when an otherwise unhandled exception or error is thrown. */ +class CrashScreen(message: String): BaseScreen() { + constructor(exception: Throwable): this(exception.stringify()) + + private companion object { + fun Throwable.stringify(): String { + val out = StringWriter() + this.printStackTrace(PrintWriter(out)) + return out.toString() + } + } + + val text = generateReportHeader() + message + var copied = false + private set + + fun generateReportHeader(): String { + return """ + Platform: ${Gdx.app.type} + Version: ${UncivGame.Current.version} + Rulesets: ${RulesetCache.keys} + + + """.trimIndent() + } + + init { + println(text) // Also print to system terminal. + stage.addActor(makeLayoutTable()) + } + + /** @return A Table containing the layout of the whole screen. */ + private fun makeLayoutTable(): Table { + val layoutTable = Table().also { + it.width = stage.width + it.height = stage.height + } + layoutTable.add(makeTitleLabel()) + .padBottom(15f) + .width(stage.width) + .row() + layoutTable.add(makeErrorScroll()) + .maxWidth(stage.width * 0.7f) + .maxHeight(stage.height * 0.5f) + .minHeight(stage.height * 0.2f) + .row() + layoutTable.add(makeInstructionLabel()) + .padTop(15f) + .width(stage.width) + .row() + layoutTable.add(makeActionButtonsTable()) + .padTop(10f) + return layoutTable + } + + /** @return Label for title at top of screen. */ + private fun makeTitleLabel() + = "An unrecoverable error has occurred in Unciv:".toLabel(fontSize = 24) + .apply { + wrap = true + setAlignment(Align.center) + } + + /** @return Actor that displays a scrollable view of the error report text. */ + private fun makeErrorScroll(): Actor { + val errorLabel = Label(text, skin).apply { + setFontSize(15) + } + val errorTable = Table() + errorTable.add(errorLabel) + .pad(10f) + return AutoScrollPane(errorTable) + .addBorder(4f, Color.DARK_GRAY) + } + + /** @return Label to give the user more information and context below the error report. */ + private fun makeInstructionLabel() + = "{If this keeps happening, you can try disabling mods.}\n{You can also report this on the issue tracker.}".toLabel() + .apply { + wrap = true + setAlignment(Align.center) + } + + /** @return Table that displays decision buttons for the bottom of the screen. */ + private fun makeActionButtonsTable(): Table { + val copyButton = "Copy".toButton() + .onClick { + Gdx.app.clipboard.contents = text + copied = true + ToastPopup( + "Error report copied.", + this@CrashScreen + ) + } + val reportButton = "Open Issue Tracker".toButton(icon = "OtherIcons/Link") + .onClick { + if (copied) { + Gdx.net.openURI("https://github.com/yairm210/Unciv/issues") + } else { + ToastPopup( + "Please copy the error report first.", + this@CrashScreen + ) + } + } + val closeButton = "Close Unciv".toButton() + .onClick { + Gdx.app.exit() + } + + val buttonsTable = Table() + buttonsTable.add(copyButton) + .pad(10f) + buttonsTable.add(reportButton) + .pad(10f) + .also { + if (isCrampedPortrait()) { + it.row() + buttonsTable.add() + } + } + buttonsTable.add(closeButton) + .pad(10f) + + return buttonsTable + } +} diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 6899e071b3..3630d13f8c 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -22,6 +22,8 @@ import com.unciv.ui.worldscreen.WorldScreen import java.util.* import kotlin.concurrent.thread + + class UncivGame(parameters: UncivGameParameters) : Game() { // we need this secondary constructor because Java code for iOS can't handle Kotlin lambda parameters constructor(version: String) : this(UncivGameParameters(version, null)) @@ -62,6 +64,10 @@ class UncivGame(parameters: UncivGameParameters) : Game() { var isInitialized = false + /** A wrapped render() method that crashes to [CrashScreen] on a unhandled exception or error. */ + private val wrappedCrashHandlingRender = { super.render() }.wrapCrashHandlingUnit() + // Stored here because I imagine that might be slightly faster than allocating for a new lambda every time, and the render loop is possibly one of the only places where that could have a significant impact. + val translations = Translations() @@ -167,6 +173,8 @@ class UncivGame(parameters: UncivGameParameters) : Game() { screen.resize(width, height) } + override fun render() = wrappedCrashHandlingRender() + override fun dispose() { cancelDiscordEvent?.invoke() Sounds.clearCache() diff --git a/core/src/com/unciv/ui/utils/BaseScreen.kt b/core/src/com/unciv/ui/utils/BaseScreen.kt index c41e6a7ec5..5fc7ad24a7 100644 --- a/core/src/com/unciv/ui/utils/BaseScreen.kt +++ b/core/src/com/unciv/ui/utils/BaseScreen.kt @@ -11,6 +11,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.* import com.badlogic.gdx.scenes.scene2d.utils.Drawable import com.badlogic.gdx.utils.viewport.ExtendViewport import com.unciv.MainMenuScreen +import com.unciv.CrashHandlingStage import com.unciv.UncivGame import com.unciv.models.Tutorial import com.unciv.ui.tutorials.TutorialController @@ -32,7 +33,7 @@ open class BaseScreen : Screen { val height = resolutions[1] /** The ExtendViewport sets the _minimum_(!) world size - the actual world size will be larger, fitted to screen/window aspect ratio. */ - stage = Stage(ExtendViewport(height, height), SpriteBatch()) + stage = CrashHandlingStage(ExtendViewport(height, height), SpriteBatch()) if (enableSceneDebug) { stage.setDebugUnderMouse(true) diff --git a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt index 213705863b..d611665c6d 100644 --- a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt +++ b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt @@ -1,13 +1,13 @@ package com.unciv.ui.utils +import com.badlogic.gdx.Gdx import com.badlogic.gdx.graphics.Color -import com.badlogic.gdx.scenes.scene2d.Actor -import com.badlogic.gdx.scenes.scene2d.InputEvent -import com.badlogic.gdx.scenes.scene2d.Stage -import com.badlogic.gdx.scenes.scene2d.Touchable +import com.badlogic.gdx.scenes.scene2d.* import com.badlogic.gdx.scenes.scene2d.ui.* import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener import com.badlogic.gdx.scenes.scene2d.utils.ClickListener +import com.unciv.CrashScreen +import com.unciv.UncivGame import com.unciv.models.UncivSound import com.unciv.models.translations.tr import java.text.SimpleDateFormat @@ -93,6 +93,19 @@ fun Actor.addBorder(size:Float, color: Color, expandCell:Boolean = false): Table return table } +/** Wrap an [Actor] in a [Group] of a given size */ +fun Actor.sizeWrapped(x: Float, y: Float): Group { + val wrapper = Group().apply { + isTransform = false // performance helper - nothing here is rotated or scaled + setSize(x, y) + } + setSize(x, y) + center(wrapper) + wrapper.addActor(this) + return wrapper +} + + /** get background Image for a new separator */ private fun getSeparatorImage(color: Color) = ImageGetter.getDot( if (color.a != 0f) color else BaseScreen.skin.get("color", Color::class.java) //0x334d80 @@ -184,6 +197,21 @@ fun Float.toPercent() = 1 + this/100 /** Translate a [String] and make a [TextButton] widget from it */ fun String.toTextButton() = TextButton(this.tr(), BaseScreen.skin) +/** Translate a [String] and make a [Button] widget from it, with control over font size, font colour, and an optional icon. */ +fun String.toButton(fontColor: Color = Color.WHITE, fontSize: Int = 24, icon: String? = null): Button { + val button = Button(BaseScreen.skin) + if (icon != null) { + val size = fontSize.toFloat() + button.add( + ImageGetter.getImage(icon).sizeWrapped(size, size) + ).padRight(size / 3) + } + button.add( + this.toLabel(fontColor, fontSize) + ) + return button +} + /** Translate a [String] and make a [Label] widget from it */ fun String.toLabel() = Label(this.tr(), BaseScreen.skin) /** Make a [Label] widget containing this [Int] as text */ @@ -284,3 +312,43 @@ object UncivDateFormat { */ fun String.parseDate(): Date = utcFormat.parse(this) } + + +/** + * Returns a wrapped version of a function that safely crashes the game to [CrashScreen] if an exception or error is thrown. + * + * In case an exception or error is thrown, the return will be null. Therefore the return type is always nullable. + * + * @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. + * */ +fun (() -> R).wrapCrashHandling( + postToMainThread: Boolean = false +): () -> R? + = { + try { + this() + } catch (e: Throwable) { + if (postToMainThread) { + Gdx.app.postRunnable { + UncivGame.Current.setScreen(CrashScreen(e)) + } + } else { + UncivGame.Current.setScreen(CrashScreen(e)) + } + null + } + } + +/** + * Returns a wrapped a version of a Unit-returning function which safely crashes the game to [CrashScreen] if an exception or error is thrown. + * + * @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. + return { wrappedReturning() ?: Unit } +} diff --git a/core/src/com/unciv/ui/utils/TabbedPager.kt b/core/src/com/unciv/ui/utils/TabbedPager.kt index 7c3e239cbb..7c895d7646 100644 --- a/core/src/com/unciv/ui/utils/TabbedPager.kt +++ b/core/src/com/unciv/ui/utils/TabbedPager.kt @@ -238,15 +238,8 @@ class TabbedPager( name = caption // enable finding pages by untranslated caption without needing our own field if (icon != null) { if (iconSize != 0f) { - val wrapper = Group().apply { - isTransform = - false // performance helper - nothing here is rotated or scaled - setSize(iconSize, iconSize) - icon.setSize(iconSize, iconSize) - icon.center(this) - addActor(icon) - } - add(wrapper).padRight(headerPadding * 0.5f) + add(icon.sizeWrapped(iconSize, iconSize)) + .padRight(headerPadding * 0.5f) } else { add(icon) }