diff --git a/core/src/com/unciv/logic/IdHelper.kt b/core/src/com/unciv/logic/IdHelper.kt new file mode 100644 index 0000000000..e839030152 --- /dev/null +++ b/core/src/com/unciv/logic/IdHelper.kt @@ -0,0 +1,117 @@ +package com.unciv.logic + +import java.util.* + +/** + * This class checks whether a Game- or Player-ID matches the old or new format. + * If old format is used, checks are skipped and input is returned. + * If new format is detected, prefix and checkDigit are checked and UUID returned. + * + * All input is returned trimmed. + * + * New format: + * G-UUID-CheckDigit for Game IDs + * P-UUID-CheckDigit for Player IDs + * + * Example: + * 2ddb3a34-0699-4126-b7a5-38603e665928 + * Same ID in proposed new Player-ID format: + * P-2ddb3a34-0699-4126-b7a5-38603e665928-5 + * Same ID in proposed new Game-ID format: + * G-2ddb3a34-0699-4126-b7a5-38603e665928-5 + */ +class IdChecker { + companion object { + fun checkAndReturnPlayerUuid(playerId: String): String { + return checkAndReturnUuiId(playerId, "P") + } + + fun checkAndReturnGameUuid(gameId: String): String { + return checkAndReturnUuiId(gameId, "G") + } + + fun checkAndReturnUuiId(id: String, prefix: String): String { + val trimmedPlayerId = id.trim() + if (trimmedPlayerId.length == 40) { // length of a UUID (36) with pre- and postfix + if (!trimmedPlayerId.startsWith(prefix, true)) { + throw IllegalArgumentException("Not a valid ID. Does not start with prefix " + prefix) + } + val checkDigit = trimmedPlayerId.substring(trimmedPlayerId.lastIndex, trimmedPlayerId.lastIndex +1) + // remember, the format is: P-9e37e983-a676-4ecc-800e-ef8ec721a9b9-5 + val shortenedPlayerId = trimmedPlayerId.substring(2, 38) + val calculatedCheckDigit = getCheckDigit(shortenedPlayerId).toString() + if (!calculatedCheckDigit.equals(checkDigit)) { + throw IllegalArgumentException("Not a valid ID. Checkdigit invalid.") + } + return shortenedPlayerId + } else if (trimmedPlayerId.length == 36) { + return trimmedPlayerId + } + throw IllegalArgumentException("Not a valid ID. Wrong length.") + } + + + /** + * Adapted from https://wiki.openmrs.org/display/docs/Check+Digit+Algorithm + */ + fun getCheckDigit(uuid: String): Int { + // allowable characters within identifier + val validChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVYWXZ-" + var idWithoutCheckdigit = uuid + // remove leading or trailing whitespace, convert to uppercase + idWithoutCheckdigit = idWithoutCheckdigit.trim().toUpperCase(Locale.ENGLISH) + + // this will be a running total + var sum = 0 + + // loop through digits from right to left + for (i in idWithoutCheckdigit.indices) { + + //set ch to "current" character to be processed + val ch = idWithoutCheckdigit.get(idWithoutCheckdigit.length - i - 1) + + // throw exception for invalid characters + if (validChars.indexOf(ch) == -1) + throw IllegalArgumentException( + ch + " is an invalid character") + + // our "digit" is calculated using ASCII value - 48 + val digit = ch.toInt() - 48 + + // weight will be the current digit's contribution to + // the running total + var weight: Int + if (i % 2 == 0) { + + // for alternating digits starting with the rightmost, we + // use our formula this is the same as multiplying x 2 and + // adding digits together for values 0 to 9. Using the + // following formula allows us to gracefully calculate a + // weight for non-numeric "digits" as well (from their + // ASCII value - 48). + weight = (2 * digit) - (digit / 5) * 9 + + } else { + + // even-positioned digits just contribute their ascii + // value minus 48 + weight = digit + + } + // keep a running total of weights + sum += weight + + } + // avoid sum less than 10 (if characters below "0" allowed, + // this could happen) + sum = Math.abs(sum) + 10 + + // check digit is amount needed to reach next number + // divisible by ten + val returnValue= (10 - (sum % 10)) % 10 + return returnValue + } + } +} + + diff --git a/core/src/com/unciv/ui/MultiplayerScreen.kt b/core/src/com/unciv/ui/MultiplayerScreen.kt index 719b6d8fa8..466269c043 100644 --- a/core/src/com/unciv/ui/MultiplayerScreen.kt +++ b/core/src/com/unciv/ui/MultiplayerScreen.kt @@ -3,9 +3,10 @@ package com.unciv.ui import com.badlogic.gdx.Gdx import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.ui.* -import com.unciv.logic.GameSaver -import com.unciv.logic.GameInfo import com.unciv.UncivGame +import com.unciv.logic.GameInfo +import com.unciv.logic.GameSaver +import com.unciv.logic.IdChecker import com.unciv.models.translations.tr import com.unciv.ui.pickerscreens.PickerScreen import com.unciv.ui.utils.* @@ -115,7 +116,7 @@ class MultiplayerScreen() : PickerScreen() { fun addMultiplayerGame(gameId: String?, gameName: String = ""){ try { //since the gameId is a String it can contain anything and has to be checked - UUID.fromString(gameId!!.trim()) + UUID.fromString(IdChecker.checkAndReturnGameUuid(gameId!!)) } catch (ex: Exception) { val errorPopup = Popup(this) errorPopup.addGoodSizedLabel("Invalid game ID!".tr()) diff --git a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt index 9e3f6f4e3b..f0a2602833 100644 --- a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt @@ -9,6 +9,7 @@ import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.GameSaver import com.unciv.logic.GameStarter +import com.unciv.logic.IdChecker import com.unciv.logic.civilization.PlayerType import com.unciv.models.ruleset.RulesetCache import com.unciv.models.translations.tr @@ -49,7 +50,7 @@ class NewGameScreen: PickerScreen(){ if (newGameParameters.isOnlineMultiplayer) { for (player in newGameParameters.players.filter { it.playerType == PlayerType.Human }) { try { - UUID.fromString(player.playerId) + UUID.fromString(IdChecker.checkAndReturnPlayerUuid(player.playerId)) } catch (ex: Exception) { val invalidPlayerIdPopup = Popup(this) invalidPlayerIdPopup.addGoodSizedLabel("Invalid player ID!".tr()).row() diff --git a/core/src/com/unciv/ui/newgamescreen/PlayerPickerTable.kt b/core/src/com/unciv/ui/newgamescreen/PlayerPickerTable.kt index 8e9e9c51af..a39dcc88f2 100644 --- a/core/src/com/unciv/ui/newgamescreen/PlayerPickerTable.kt +++ b/core/src/com/unciv/ui/newgamescreen/PlayerPickerTable.kt @@ -9,13 +9,13 @@ import com.badlogic.gdx.scenes.scene2d.ui.TextButton import com.badlogic.gdx.scenes.scene2d.ui.TextField import com.badlogic.gdx.utils.Align import com.unciv.UncivGame +import com.unciv.logic.IdChecker import com.unciv.logic.civilization.PlayerType import com.unciv.models.metadata.GameParameters import com.unciv.models.metadata.Player import com.unciv.models.ruleset.Ruleset import com.unciv.models.translations.tr import com.unciv.ui.utils.* -import com.unciv.ui.utils.Popup import java.util.* class PlayerPickerTable(val newGameScreen: NewGameScreen, val newGameParameters: GameParameters): Table() { @@ -72,8 +72,8 @@ class PlayerPickerTable(val newGameScreen: NewGameScreen, val newGameParameters: fun onPlayerIdTextUpdated(){ try { - val uuid = UUID.fromString(playerIdTextfield.text) - player.playerId = playerIdTextfield.text + UUID.fromString(IdChecker.checkAndReturnPlayerUuid(playerIdTextfield.text)) + player.playerId = playerIdTextfield.text.trim() errorLabel.apply { setText("✔");setFontColor(Color.GREEN) } } catch (ex: Exception) { errorLabel.apply { setText("✘");setFontColor(Color.RED) } diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt index 41a60166bb..0d3d4a0fbd 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt @@ -3,9 +3,9 @@ package com.unciv.ui.worldscreen.mainmenu import com.badlogic.gdx.Gdx import com.badlogic.gdx.graphics.Color import com.unciv.UncivGame +import com.unciv.logic.IdChecker import com.unciv.models.translations.tr import com.unciv.ui.CivilopediaScreen -import com.unciv.ui.victoryscreen.VictoryScreen import com.unciv.ui.MultiplayerScreen import com.unciv.ui.mapeditor.LoadMapScreen import com.unciv.ui.mapeditor.NewMapScreen @@ -13,6 +13,7 @@ import com.unciv.ui.newgamescreen.NewGameScreen import com.unciv.ui.saves.LoadGameScreen import com.unciv.ui.saves.SaveGameScreen import com.unciv.ui.utils.* +import com.unciv.ui.victoryscreen.VictoryScreen import com.unciv.ui.worldscreen.WorldScreen import java.util.* import kotlin.concurrent.thread @@ -105,7 +106,7 @@ class WorldScreenMenuPopup(val worldScreen: WorldScreen) : Popup(worldScreen) { multiplayerPopup.addButton("Join Game") { val gameId = Gdx.app.clipboard.contents try { - UUID.fromString(gameId.trim()) + UUID.fromString(IdChecker.checkAndReturnGameUuid(gameId)) } catch (ex: Exception) { badGameIdLabel.setText("Invalid game ID!") badGameIdLabel.isVisible = true diff --git a/tests/src/com/unciv/testing/IdHelperTests.kt b/tests/src/com/unciv/testing/IdHelperTests.kt new file mode 100644 index 0000000000..25dfd973e6 --- /dev/null +++ b/tests/src/com/unciv/testing/IdHelperTests.kt @@ -0,0 +1,76 @@ +package com.unciv.testing + +import com.unciv.logic.IdChecker +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(GdxTestRunner::class) +class IdHelperTests { + @Test + fun testCheckDigits() { + val correctString = "2ddb3a34-0699-4126-b7a5-38603e665928" + val inCorrectString1 = "2ddb3a34-0699-4126-b7a5-38603e665929" + val inCorrectString2 = "2ddb3a34-0969-4126-b7a5-38603e665928" + val inCorrectString3 = "2ddb3a34-0699-4126-b7a" + val inCorrectString4 = "0699-4126-b7a5-38603e665928-2ddb3a34" + + val correctLuhn = IdChecker.getCheckDigit(correctString) + val correctLuhn2 = IdChecker.getCheckDigit(correctString) + val inCorrectLuhn1 = IdChecker.getCheckDigit(inCorrectString1) + val inCorrectLuhn2 = IdChecker.getCheckDigit(inCorrectString2) + val inCorrectLuhn3 = IdChecker.getCheckDigit(inCorrectString3) + val inCorrectLuhn4 = IdChecker.getCheckDigit(inCorrectString4) + + Assert.assertEquals(correctLuhn, correctLuhn2) + Assert.assertNotEquals(inCorrectLuhn1, correctLuhn) + Assert.assertNotEquals(inCorrectLuhn2, correctLuhn) + Assert.assertNotEquals(inCorrectLuhn3, correctLuhn) + Assert.assertNotEquals(inCorrectLuhn4, correctLuhn) + + Assert.assertNotEquals(inCorrectLuhn1, inCorrectLuhn2) + Assert.assertNotEquals(inCorrectLuhn1, inCorrectLuhn3) + Assert.assertNotEquals(inCorrectLuhn1, inCorrectLuhn4) + + Assert.assertNotEquals(inCorrectLuhn2, inCorrectLuhn3) + Assert.assertNotEquals(inCorrectLuhn2, inCorrectLuhn4) + + Assert.assertNotEquals(inCorrectLuhn3, inCorrectLuhn4) + } + + @Test + fun testIdsSuccess() { + val correctString = "2ddb3a34-0699-4126-b7a5-38603e665928" + + Assert.assertEquals(correctString, IdChecker.checkAndReturnPlayerUuid(correctString)) + Assert.assertEquals("c872b8e0-f274-47d4-b761-ce684c5d224c", IdChecker.checkAndReturnGameUuid("c872b8e0-f274-47d4-b761-ce684c5d224c")) + + Assert.assertEquals(correctString, IdChecker.checkAndReturnGameUuid("G-" + correctString + "-2")) + Assert.assertEquals(correctString, IdChecker.checkAndReturnPlayerUuid("P-" + correctString + "-2")) + } + + @Test(expected = IllegalArgumentException::class) + fun testIdFailure1() { + IdChecker.checkAndReturnGameUuid("2ddb3a34-0699-4126-b7a5-38603e66592") // too short + } + + @Test(expected = IllegalArgumentException::class) + fun testIdFailure2() { + IdChecker.checkAndReturnGameUuid("P-2ddb3a34-0699-4126-b7a5-38603e665928-2") // wrong prefix + } + + @Test(expected = IllegalArgumentException::class) + fun testIdFailure3() { + IdChecker.checkAndReturnPlayerUuid("G-2ddb3a34-0699-4126-b7a5-38603e665928-2") // wrong prefix + } + + @Test(expected = IllegalArgumentException::class) + fun testIdFailure4() { + IdChecker.checkAndReturnGameUuid("G-2ddb3a34-0699-4126-b7a5-38603e665928-3") // changed checkDigit + } + + @Test(expected = IllegalArgumentException::class) + fun testIdFailure5() { + IdChecker.checkAndReturnGameUuid("G-2ddb3a34-0699-4126-b7a5-48603e665928-2") // changed uuid without changing checkdigit + } +} \ No newline at end of file