Implement custom save locations for Android and Desktop (#3160)
* Implement custom save locations for Android and Desktop * Request write permission to save to external storage * Fix race condition for custom saves/loads caused by autosaves * Remove unnecessary WRITE_EXTERNAL_STORAGE permission for saving files * Fix padding for custom save/load location buttons * Use nullability checks as defined in coding style guide * Use nullability checks as defined in coding style guide * Use early return for readability * Rename save/load completion callbacks for custom locations and implement error handling
This commit is contained in:
parent
fded66b523
commit
205b5ccfea
11 changed files with 362 additions and 11 deletions
|
@ -1,5 +1,6 @@
|
||||||
package com.unciv.app
|
package com.unciv.app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
@ -13,8 +14,12 @@ import com.unciv.ui.utils.ORIGINAL_FONT_SIZE
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class AndroidLauncher : AndroidApplication() {
|
class AndroidLauncher : AndroidApplication() {
|
||||||
|
private var customSaveLocationHelper: CustomSaveLocationHelperAndroid? = null
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||||
|
customSaveLocationHelper = CustomSaveLocationHelperAndroid(this)
|
||||||
|
}
|
||||||
MultiplayerTurnCheckWorker.createNotificationChannels(applicationContext)
|
MultiplayerTurnCheckWorker.createNotificationChannels(applicationContext)
|
||||||
|
|
||||||
// Only allow mods on KK+, to avoid READ_EXTERNAL_STORAGE permission earlier versions need
|
// Only allow mods on KK+, to avoid READ_EXTERNAL_STORAGE permission earlier versions need
|
||||||
|
@ -29,7 +34,8 @@ class AndroidLauncher : AndroidApplication() {
|
||||||
version = BuildConfig.VERSION_NAME,
|
version = BuildConfig.VERSION_NAME,
|
||||||
crashReportSender = CrashReportSenderAndroid(this),
|
crashReportSender = CrashReportSenderAndroid(this),
|
||||||
exitEvent = this::finish,
|
exitEvent = this::finish,
|
||||||
fontImplementation = NativeFontAndroid(ORIGINAL_FONT_SIZE.toInt())
|
fontImplementation = NativeFontAndroid(ORIGINAL_FONT_SIZE.toInt()),
|
||||||
|
customSaveLocationHelper = customSaveLocationHelper
|
||||||
)
|
)
|
||||||
val game = UncivGame ( androidParameters )
|
val game = UncivGame ( androidParameters )
|
||||||
initialize(game, config)
|
initialize(game, config)
|
||||||
|
@ -77,4 +83,13 @@ class AndroidLauncher : AndroidApplication() {
|
||||||
catch (ex:Exception){}
|
catch (ex:Exception){}
|
||||||
super.onResume()
|
super.onResume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||||
|
// This should only happen on API 19+ but it's wrapped in the if check to keep the
|
||||||
|
// compiler happy
|
||||||
|
customSaveLocationHelper?.handleIntentData(requestCode, data?.data)
|
||||||
|
}
|
||||||
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
}
|
||||||
}
|
}
|
125
android/src/com/unciv/app/CustomSaveLocationHelperAndroid.kt
Normal file
125
android/src/com/unciv/app/CustomSaveLocationHelperAndroid.kt
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
package com.unciv.app
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.GuardedBy
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import com.unciv.logic.CustomSaveLocationHelper
|
||||||
|
import com.unciv.logic.GameInfo
|
||||||
|
import com.unciv.logic.GameSaver
|
||||||
|
import com.unciv.logic.GameSaver.json
|
||||||
|
|
||||||
|
// The Storage Access Framework is available from API 19 and up:
|
||||||
|
// https://developer.android.com/guide/topics/providers/document-provider
|
||||||
|
@RequiresApi(Build.VERSION_CODES.KITKAT)
|
||||||
|
class CustomSaveLocationHelperAndroid(private val activity: Activity) : CustomSaveLocationHelper {
|
||||||
|
// This looks a little scary but it's really not so bad. Whenever a load or save operation is
|
||||||
|
// attempted, the game automatically autosaves as well (but on a separate thread), so we end up
|
||||||
|
// with a race condition when trying to handle both operations in parallel. In order to work
|
||||||
|
// around that, the callbacks are given an arbitrary index beginning at 100 and incrementing
|
||||||
|
// each time, and this index is used as the requestCode for the call to startActivityForResult()
|
||||||
|
// so that we can map it back to the corresponding callback when onActivityResult is called
|
||||||
|
@GuardedBy("this")
|
||||||
|
@Volatile
|
||||||
|
private var callbackIndex = 100
|
||||||
|
|
||||||
|
@GuardedBy("this")
|
||||||
|
private val callbacks = ArrayList<IndexedCallback>()
|
||||||
|
|
||||||
|
override fun saveGame(gameInfo: GameInfo, gameName: String, forcePrompt: Boolean, saveCompleteCallback: ((Exception?) -> Unit)?) {
|
||||||
|
val callbackIndex = synchronized(this) {
|
||||||
|
val index = callbackIndex++
|
||||||
|
callbacks.add(IndexedCallback(
|
||||||
|
index,
|
||||||
|
{ uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
saveGame(gameInfo, uri)
|
||||||
|
saveCompleteCallback?.invoke(null)
|
||||||
|
} else {
|
||||||
|
saveCompleteCallback?.invoke(RuntimeException("Uri was null"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
index
|
||||||
|
}
|
||||||
|
if (!forcePrompt && gameInfo.customSaveLocation != null) {
|
||||||
|
handleIntentData(callbackIndex, Uri.parse(gameInfo.customSaveLocation))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||||
|
type = "application/json"
|
||||||
|
putExtra(Intent.EXTRA_TITLE, gameName)
|
||||||
|
activity.startActivityForResult(this, callbackIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// This will be called on the main thread
|
||||||
|
fun handleIntentData(requestCode: Int, uri: Uri?) {
|
||||||
|
val callback = synchronized(this) {
|
||||||
|
val index = callbacks.indexOfFirst { it.index == requestCode }
|
||||||
|
if (index == -1) return
|
||||||
|
callbacks.removeAt(index)
|
||||||
|
}
|
||||||
|
callback.thread.run {
|
||||||
|
callback.callback(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveGame(gameInfo: GameInfo, uri: Uri) {
|
||||||
|
gameInfo.customSaveLocation = uri.toString()
|
||||||
|
activity.contentResolver.openOutputStream(uri, "rwt")
|
||||||
|
?.writer()
|
||||||
|
?.use {
|
||||||
|
it.write(json().toJson(gameInfo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadGame(loadCompleteCallback: (GameInfo?, Exception?) -> Unit) {
|
||||||
|
val callbackIndex = synchronized(this) {
|
||||||
|
val index = callbackIndex++
|
||||||
|
callbacks.add(IndexedCallback(
|
||||||
|
index,
|
||||||
|
callback@{ uri ->
|
||||||
|
if (uri == null) return@callback
|
||||||
|
var exception: Exception? = null
|
||||||
|
val game = try {
|
||||||
|
activity.contentResolver.openInputStream(uri)
|
||||||
|
?.reader()
|
||||||
|
?.readText()
|
||||||
|
?.run {
|
||||||
|
GameSaver.gameInfoFromString(this)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
exception = e
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (game != null) {
|
||||||
|
// If the user has saved the game from another platform (like Android),
|
||||||
|
// then the save location might not be right so we have to correct for that
|
||||||
|
// here
|
||||||
|
game.customSaveLocation = uri.toString()
|
||||||
|
loadCompleteCallback(game, null)
|
||||||
|
} else {
|
||||||
|
loadCompleteCallback(null, RuntimeException("Failed to load save game", exception))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
index
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
|
type = "*/*"
|
||||||
|
activity.startActivityForResult(this, callbackIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class IndexedCallback(
|
||||||
|
val index: Int,
|
||||||
|
val callback: (Uri?) -> Unit,
|
||||||
|
val thread: Thread = Thread.currentThread()
|
||||||
|
)
|
|
@ -13,7 +13,10 @@ import com.unciv.models.metadata.GameSettings
|
||||||
import com.unciv.models.ruleset.RulesetCache
|
import com.unciv.models.ruleset.RulesetCache
|
||||||
import com.unciv.models.translations.Translations
|
import com.unciv.models.translations.Translations
|
||||||
import com.unciv.ui.LanguagePickerScreen
|
import com.unciv.ui.LanguagePickerScreen
|
||||||
import com.unciv.ui.utils.*
|
import com.unciv.ui.utils.CameraStageBaseScreen
|
||||||
|
import com.unciv.ui.utils.CrashController
|
||||||
|
import com.unciv.ui.utils.ImageGetter
|
||||||
|
import com.unciv.ui.utils.center
|
||||||
import com.unciv.ui.worldscreen.WorldScreen
|
import com.unciv.ui.worldscreen.WorldScreen
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
|
@ -28,6 +31,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
|
||||||
val cancelDiscordEvent = parameters.cancelDiscordEvent
|
val cancelDiscordEvent = parameters.cancelDiscordEvent
|
||||||
val fontImplementation = parameters.fontImplementation
|
val fontImplementation = parameters.fontImplementation
|
||||||
val consoleMode = parameters.consoleMode
|
val consoleMode = parameters.consoleMode
|
||||||
|
val customSaveLocationHelper = parameters.customSaveLocationHelper
|
||||||
|
|
||||||
lateinit var gameInfo: GameInfo
|
lateinit var gameInfo: GameInfo
|
||||||
fun isGameInfoInitialized() = this::gameInfo.isInitialized
|
fun isGameInfoInitialized() = this::gameInfo.isInitialized
|
||||||
|
@ -69,6 +73,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
|
||||||
Current = this
|
Current = this
|
||||||
|
|
||||||
|
|
||||||
|
GameSaver.customSaveLocationHelper = customSaveLocationHelper
|
||||||
// If this takes too long players, especially with older phones, get ANR problems.
|
// If this takes too long players, especially with older phones, get ANR problems.
|
||||||
// Whatever needs graphics needs to be done on the main thread,
|
// Whatever needs graphics needs to be done on the main thread,
|
||||||
// So it's basically a long set of deferred actions.
|
// So it's basically a long set of deferred actions.
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.unciv
|
package com.unciv
|
||||||
|
|
||||||
|
import com.unciv.logic.CustomSaveLocationHelper
|
||||||
import com.unciv.ui.utils.CrashReportSender
|
import com.unciv.ui.utils.CrashReportSender
|
||||||
import com.unciv.ui.utils.NativeFontImplementation
|
import com.unciv.ui.utils.NativeFontImplementation
|
||||||
|
|
||||||
|
@ -8,5 +9,6 @@ class UncivGameParameters(val version: String,
|
||||||
val exitEvent: (()->Unit)? = null,
|
val exitEvent: (()->Unit)? = null,
|
||||||
val cancelDiscordEvent: (()->Unit)? = null,
|
val cancelDiscordEvent: (()->Unit)? = null,
|
||||||
val fontImplementation: NativeFontImplementation? = null,
|
val fontImplementation: NativeFontImplementation? = null,
|
||||||
val consoleMode: Boolean = false) {
|
val consoleMode: Boolean = false,
|
||||||
|
val customSaveLocationHelper: CustomSaveLocationHelper? = null) {
|
||||||
}
|
}
|
28
core/src/com/unciv/logic/CustomSaveLocationHelper.kt
Normal file
28
core/src/com/unciv/logic/CustomSaveLocationHelper.kt
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
package com.unciv.logic
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for platform-specific helper classes to handle saving and loading games to and from
|
||||||
|
* arbitrary external locations
|
||||||
|
*/
|
||||||
|
interface CustomSaveLocationHelper {
|
||||||
|
/**
|
||||||
|
* Saves a game asynchronously with a given default name and then calls the [saveCompleteCallback] callback
|
||||||
|
* upon completion. The [saveCompleteCallback] callback will be called from the same thread that this method
|
||||||
|
* is called from. If the [GameInfo] object already has the
|
||||||
|
* [customSaveLocation][GameInfo.customSaveLocation] property defined (not null), then the user
|
||||||
|
* will not be prompted to select a location for the save unless [forcePrompt] is set to true
|
||||||
|
* (think of this like "Save as...")
|
||||||
|
*/
|
||||||
|
fun saveGame(
|
||||||
|
gameInfo: GameInfo,
|
||||||
|
gameName: String,
|
||||||
|
forcePrompt: Boolean = false,
|
||||||
|
saveCompleteCallback: ((Exception?) -> Unit)? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a game from an external source asynchronously, then calls [loadCompleteCallback] with the loaded
|
||||||
|
* [GameInfo]
|
||||||
|
*/
|
||||||
|
fun loadGame(loadCompleteCallback: (GameInfo?, Exception?) -> Unit)
|
||||||
|
}
|
|
@ -35,6 +35,8 @@ class GameInfo {
|
||||||
var oneMoreTurnMode=false
|
var oneMoreTurnMode=false
|
||||||
var currentPlayer=""
|
var currentPlayer=""
|
||||||
var gameId = UUID.randomUUID().toString() // random string
|
var gameId = UUID.randomUUID().toString() // random string
|
||||||
|
@Volatile
|
||||||
|
var customSaveLocation: String? = null
|
||||||
|
|
||||||
/** Simulate until any player wins,
|
/** Simulate until any player wins,
|
||||||
* or turns exceeds indicated number
|
* or turns exceeds indicated number
|
||||||
|
@ -55,6 +57,7 @@ class GameInfo {
|
||||||
toReturn.gameParameters = gameParameters
|
toReturn.gameParameters = gameParameters
|
||||||
toReturn.gameId = gameId
|
toReturn.gameId = gameId
|
||||||
toReturn.oneMoreTurnMode = oneMoreTurnMode
|
toReturn.oneMoreTurnMode = oneMoreTurnMode
|
||||||
|
toReturn.customSaveLocation = customSaveLocation
|
||||||
return toReturn
|
return toReturn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,8 @@ object GameSaver {
|
||||||
private const val saveFilesFolder = "SaveFiles"
|
private const val saveFilesFolder = "SaveFiles"
|
||||||
private const val multiplayerFilesFolder = "MultiplayerGames"
|
private const val multiplayerFilesFolder = "MultiplayerGames"
|
||||||
private const val settingsFileName = "GameSettings.json"
|
private const val settingsFileName = "GameSettings.json"
|
||||||
|
@Volatile
|
||||||
|
var customSaveLocationHelper: CustomSaveLocationHelper? = null
|
||||||
|
|
||||||
/** When set, we know we're on Android and can save to the app's personal external file directory
|
/** When set, we know we're on Android and can save to the app's personal external file directory
|
||||||
* See https://developer.android.com/training/data-storage/app-specific#external-access-files */
|
* See https://developer.android.com/training/data-storage/app-specific#external-access-files */
|
||||||
|
@ -37,8 +39,19 @@ object GameSaver {
|
||||||
return localSaves + Gdx.files.absolute(externalFilesDirForAndroid + "/${getSubfolder(multiplayer)}").list().asSequence()
|
return localSaves + Gdx.files.absolute(externalFilesDirForAndroid + "/${getSubfolder(multiplayer)}").list().asSequence()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveGame(game: GameInfo, GameName: String, multiplayer: Boolean = false) {
|
fun saveGame(game: GameInfo, GameName: String, multiplayer: Boolean = false, forcePrompt: Boolean = false, saveCompletionCallback: ((Exception?) -> Unit)? = null) {
|
||||||
json().toJson(game,getSave(GameName, multiplayer))
|
val customSaveLocation = game.customSaveLocation
|
||||||
|
val customSaveLocationHelper = this.customSaveLocationHelper
|
||||||
|
if (customSaveLocation != null && customSaveLocationHelper != null) {
|
||||||
|
customSaveLocationHelper.saveGame(game, GameName, forcePrompt, saveCompletionCallback)
|
||||||
|
} else {
|
||||||
|
json().toJson(game, getSave(GameName, multiplayer))
|
||||||
|
saveCompletionCallback?.invoke(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveGameToCustomLocation(game: GameInfo, GameName: String, saveCompletionCallback: (Exception?) -> Unit) {
|
||||||
|
customSaveLocationHelper!!.saveGame(game, GameName, forcePrompt = true, saveCompleteCallback = saveCompletionCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadGameByName(GameName: String, multiplayer: Boolean = false) =
|
fun loadGameByName(GameName: String, multiplayer: Boolean = false) =
|
||||||
|
@ -50,7 +63,15 @@ object GameSaver {
|
||||||
return game
|
return game
|
||||||
}
|
}
|
||||||
|
|
||||||
fun gameInfoFromString(gameData:String): GameInfo {
|
fun loadGameFromCustomLocation(loadCompletionCallback: (GameInfo?, Exception?) -> Unit) {
|
||||||
|
customSaveLocationHelper!!.loadGame { game, e ->
|
||||||
|
loadCompletionCallback(game?.apply { setTransients() }, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canLoadFromCustomSaveLocation() = customSaveLocationHelper != null
|
||||||
|
|
||||||
|
fun gameInfoFromString(gameData: String): GameInfo {
|
||||||
val game = json().fromJson(GameInfo::class.java, gameData)
|
val game = json().fromJson(GameInfo::class.java, gameData)
|
||||||
game.setTransients()
|
game.setTransients()
|
||||||
return game
|
return game
|
||||||
|
@ -107,14 +128,26 @@ object GameSaver {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun autoSaveSingleThreaded (gameInfo: GameInfo) {
|
fun autoSaveSingleThreaded (gameInfo: GameInfo) {
|
||||||
|
// If the user has chosen a custom save location outside of the usual game directories,
|
||||||
|
// they'll probably expect us to overwrite that instead. E.g. if the user is saving their
|
||||||
|
// game to their Google Drive folder, they'll probably want that progress to be synced to
|
||||||
|
// other devices automatically so they don't have to remember to manually save before
|
||||||
|
// exiting the game.
|
||||||
|
if (gameInfo.customSaveLocation != null) {
|
||||||
|
// GameName is unused here since we're just overwriting the existing file
|
||||||
|
saveGame(gameInfo, "", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
saveGame(gameInfo, "Autosave")
|
saveGame(gameInfo, "Autosave")
|
||||||
|
|
||||||
// keep auto-saves for the last 10 turns for debugging purposes
|
// keep auto-saves for the last 10 turns for debugging purposes
|
||||||
val newAutosaveFilename = saveFilesFolder + File.separator + "Autosave-${gameInfo.currentPlayer}-${gameInfo.turns}"
|
val newAutosaveFilename = saveFilesFolder + File.separator + "Autosave-${gameInfo.currentPlayer}-${gameInfo.turns}"
|
||||||
getSave("Autosave").copyTo(Gdx.files.local(newAutosaveFilename))
|
getSave("Autosave").copyTo(Gdx.files.local(newAutosaveFilename))
|
||||||
|
|
||||||
fun getAutosaves(): Sequence<FileHandle> { return getSaves().filter { it.name().startsWith("Autosave") } }
|
fun getAutosaves(): Sequence<FileHandle> {
|
||||||
while(getAutosaves().count()>10){
|
return getSaves().filter { it.name().startsWith("Autosave") }
|
||||||
|
}
|
||||||
|
while (getAutosaves().count() > 10) {
|
||||||
val saveToDelete = getAutosaves().minBy { it.lastModified() }!!
|
val saveToDelete = getAutosaves().minBy { it.lastModified() }!!
|
||||||
deleteSave(saveToDelete.name())
|
deleteSave(saveToDelete.name())
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import com.unciv.ui.pickerscreens.PickerScreen
|
||||||
import com.unciv.ui.utils.*
|
import com.unciv.ui.utils.*
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import java.util.concurrent.CancellationException
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import com.unciv.ui.utils.AutoScrollPane as ScrollPane
|
import com.unciv.ui.utils.AutoScrollPane as ScrollPane
|
||||||
|
|
||||||
|
@ -78,6 +79,20 @@ class LoadGameScreen(previousScreen:CameraStageBaseScreen) : PickerScreen() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rightSideTable.add(loadFromClipboardButton).row()
|
rightSideTable.add(loadFromClipboardButton).row()
|
||||||
|
if (GameSaver.canLoadFromCustomSaveLocation()) {
|
||||||
|
val loadFromCustomLocation = "Load from custom location".toTextButton()
|
||||||
|
loadFromCustomLocation.onClick {
|
||||||
|
GameSaver.loadGameFromCustomLocation { gameInfo, exception ->
|
||||||
|
if (gameInfo != null) {
|
||||||
|
game.loadGame(gameInfo)
|
||||||
|
} else if (exception !is CancellationException) {
|
||||||
|
errorLabel.setText("Could not load game from custom location!".tr())
|
||||||
|
exception?.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rightSideTable.add(loadFromCustomLocation).pad(10f).row()
|
||||||
|
}
|
||||||
rightSideTable.add(errorLabel).row()
|
rightSideTable.add(errorLabel).row()
|
||||||
|
|
||||||
deleteSaveButton.onClick {
|
deleteSaveButton.onClick {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package com.unciv.ui.saves
|
package com.unciv.ui.saves
|
||||||
|
|
||||||
import com.badlogic.gdx.Gdx
|
import com.badlogic.gdx.Gdx
|
||||||
|
import com.badlogic.gdx.graphics.Color
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.CheckBox
|
import com.badlogic.gdx.scenes.scene2d.ui.CheckBox
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.TextField
|
import com.badlogic.gdx.scenes.scene2d.ui.TextField
|
||||||
|
@ -10,6 +11,7 @@ import com.unciv.logic.GameSaver
|
||||||
import com.unciv.models.translations.tr
|
import com.unciv.models.translations.tr
|
||||||
import com.unciv.ui.pickerscreens.PickerScreen
|
import com.unciv.ui.pickerscreens.PickerScreen
|
||||||
import com.unciv.ui.utils.*
|
import com.unciv.ui.utils.*
|
||||||
|
import java.util.concurrent.CancellationException
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import com.unciv.ui.utils.AutoScrollPane as ScrollPane
|
import com.unciv.ui.utils.AutoScrollPane as ScrollPane
|
||||||
|
|
||||||
|
@ -40,6 +42,29 @@ class SaveGameScreen : PickerScreen() {
|
||||||
Gdx.app.clipboard.contents = base64Gzip
|
Gdx.app.clipboard.contents = base64Gzip
|
||||||
}
|
}
|
||||||
newSave.add(copyJsonButton).row()
|
newSave.add(copyJsonButton).row()
|
||||||
|
if (GameSaver.canLoadFromCustomSaveLocation()) {
|
||||||
|
val saveToCustomLocation = "Save to custom location".toTextButton()
|
||||||
|
val errorLabel = "".toLabel(Color.RED)
|
||||||
|
saveToCustomLocation.enable()
|
||||||
|
saveToCustomLocation.onClick {
|
||||||
|
errorLabel.setText("")
|
||||||
|
saveToCustomLocation.setText("Saving...".tr())
|
||||||
|
saveToCustomLocation.disable()
|
||||||
|
thread(name = "SaveGame") {
|
||||||
|
GameSaver.saveGameToCustomLocation(UncivGame.Current.gameInfo, textField.text) { e ->
|
||||||
|
if (e == null) {
|
||||||
|
Gdx.app.postRunnable { UncivGame.Current.setWorldScreen() }
|
||||||
|
} else if (e !is CancellationException) {
|
||||||
|
errorLabel.setText("Could not save game to custom location".tr())
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
saveToCustomLocation.enable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newSave.add(saveToCustomLocation).pad(10f).row()
|
||||||
|
newSave.add(errorLabel).pad(0f, 10f, 10f, 10f).row()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
val showAutosavesCheckbox = CheckBox("Show autosaves".tr(), skin)
|
val showAutosavesCheckbox = CheckBox("Show autosaves".tr(), skin)
|
||||||
|
@ -56,8 +81,9 @@ class SaveGameScreen : PickerScreen() {
|
||||||
rightSideButton.onClick {
|
rightSideButton.onClick {
|
||||||
rightSideButton.setText("Saving...".tr())
|
rightSideButton.setText("Saving...".tr())
|
||||||
thread(name = "SaveGame") {
|
thread(name = "SaveGame") {
|
||||||
GameSaver.saveGame(UncivGame.Current.gameInfo, textField.text)
|
GameSaver.saveGame(UncivGame.Current.gameInfo, textField.text) {
|
||||||
Gdx.app.postRunnable { UncivGame.Current.setWorldScreen() }
|
Gdx.app.postRunnable { UncivGame.Current.setWorldScreen() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rightSideButton.enable()
|
rightSideButton.enable()
|
||||||
|
|
|
@ -0,0 +1,98 @@
|
||||||
|
package com.unciv.app.desktop
|
||||||
|
|
||||||
|
import com.badlogic.gdx.Gdx
|
||||||
|
import com.unciv.logic.CustomSaveLocationHelper
|
||||||
|
import com.unciv.logic.GameInfo
|
||||||
|
import com.unciv.logic.GameSaver
|
||||||
|
import com.unciv.logic.GameSaver.json
|
||||||
|
import java.awt.event.WindowEvent
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.CancellationException
|
||||||
|
import javax.swing.JFileChooser
|
||||||
|
import javax.swing.JFrame
|
||||||
|
|
||||||
|
class CustomSaveLocationHelperDesktop : CustomSaveLocationHelper {
|
||||||
|
override fun saveGame(gameInfo: GameInfo, gameName: String, forcePrompt: Boolean, saveCompleteCallback: ((Exception?) -> Unit)?) {
|
||||||
|
val customSaveLocation = gameInfo.customSaveLocation
|
||||||
|
if (customSaveLocation != null && !forcePrompt) {
|
||||||
|
try {
|
||||||
|
File(customSaveLocation).outputStream()
|
||||||
|
.writer()
|
||||||
|
.use { writer ->
|
||||||
|
writer.write(json().toJson(gameInfo))
|
||||||
|
}
|
||||||
|
saveCompleteCallback?.invoke(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
saveCompleteCallback?.invoke(e)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileChooser = JFileChooser().apply fileChooser@{
|
||||||
|
currentDirectory = Gdx.files.local("").file()
|
||||||
|
selectedFile = File(gameInfo.customSaveLocation ?: gameName)
|
||||||
|
}
|
||||||
|
|
||||||
|
JFrame().apply frame@{
|
||||||
|
setLocationRelativeTo(null)
|
||||||
|
isVisible = true
|
||||||
|
toFront()
|
||||||
|
fileChooser.showSaveDialog(this@frame)
|
||||||
|
dispatchEvent(WindowEvent(this, WindowEvent.WINDOW_CLOSING))
|
||||||
|
}
|
||||||
|
val file = fileChooser.selectedFile
|
||||||
|
var exception: Exception? = null
|
||||||
|
if (file != null) {
|
||||||
|
gameInfo.customSaveLocation = file.absolutePath
|
||||||
|
try {
|
||||||
|
file.outputStream()
|
||||||
|
.writer()
|
||||||
|
.use {
|
||||||
|
it.write(json().toJson(gameInfo))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
exception = e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
exception = CancellationException()
|
||||||
|
}
|
||||||
|
saveCompleteCallback?.invoke(exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadGame(loadCompleteCallback: (GameInfo?, Exception?) -> Unit) {
|
||||||
|
val fileChooser = JFileChooser().apply fileChooser@{
|
||||||
|
currentDirectory = Gdx.files.local("").file()
|
||||||
|
}
|
||||||
|
|
||||||
|
JFrame().apply frame@{
|
||||||
|
setLocationRelativeTo(null)
|
||||||
|
isVisible = true
|
||||||
|
toFront()
|
||||||
|
fileChooser.showOpenDialog(this@frame)
|
||||||
|
dispatchEvent(WindowEvent(this, WindowEvent.WINDOW_CLOSING))
|
||||||
|
}
|
||||||
|
val file = fileChooser.selectedFile
|
||||||
|
var exception: Exception? = null
|
||||||
|
var gameInfo: GameInfo? = null
|
||||||
|
if (file != null) {
|
||||||
|
try {
|
||||||
|
file.inputStream()
|
||||||
|
.reader()
|
||||||
|
.readText()
|
||||||
|
.run { GameSaver.gameInfoFromString(this) }
|
||||||
|
.apply {
|
||||||
|
// If the user has saved the game from another platform (like Android),
|
||||||
|
// then the save location might not be right so we have to correct for that
|
||||||
|
// here
|
||||||
|
customSaveLocation = file.absolutePath
|
||||||
|
gameInfo = this
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
exception = e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
exception = CancellationException()
|
||||||
|
}
|
||||||
|
loadCompleteCallback(gameInfo, exception)
|
||||||
|
}
|
||||||
|
}
|
|
@ -47,7 +47,8 @@ internal object DesktopLauncher {
|
||||||
versionFromJar,
|
versionFromJar,
|
||||||
exitEvent = { exitProcess(0) },
|
exitEvent = { exitProcess(0) },
|
||||||
cancelDiscordEvent = { discordTimer?.cancel() },
|
cancelDiscordEvent = { discordTimer?.cancel() },
|
||||||
fontImplementation = NativeFontDesktop(ORIGINAL_FONT_SIZE.toInt())
|
fontImplementation = NativeFontDesktop(ORIGINAL_FONT_SIZE.toInt()),
|
||||||
|
customSaveLocationHelper = CustomSaveLocationHelperDesktop()
|
||||||
)
|
)
|
||||||
|
|
||||||
val game = UncivGame ( desktopParameters )
|
val game = UncivGame ( desktopParameters )
|
||||||
|
|
Loading…
Reference in a new issue