diff --git a/android/src/com/unciv/app/AndroidLauncher.kt b/android/src/com/unciv/app/AndroidLauncher.kt index 63dcd692..f3308ec3 100644 --- a/android/src/com/unciv/app/AndroidLauncher.kt +++ b/android/src/com/unciv/app/AndroidLauncher.kt @@ -1,5 +1,6 @@ package com.unciv.app +import android.content.Intent import android.os.Build import android.os.Bundle import androidx.core.app.NotificationManagerCompat @@ -13,8 +14,12 @@ import com.unciv.ui.utils.ORIGINAL_FONT_SIZE import java.io.File class AndroidLauncher : AndroidApplication() { + private var customSaveLocationHelper: CustomSaveLocationHelperAndroid? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + customSaveLocationHelper = CustomSaveLocationHelperAndroid(this) + } MultiplayerTurnCheckWorker.createNotificationChannels(applicationContext) // 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, crashReportSender = CrashReportSenderAndroid(this), exitEvent = this::finish, - fontImplementation = NativeFontAndroid(ORIGINAL_FONT_SIZE.toInt()) + fontImplementation = NativeFontAndroid(ORIGINAL_FONT_SIZE.toInt()), + customSaveLocationHelper = customSaveLocationHelper ) val game = UncivGame ( androidParameters ) initialize(game, config) @@ -77,4 +83,13 @@ class AndroidLauncher : AndroidApplication() { catch (ex:Exception){} 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) + } } \ No newline at end of file diff --git a/android/src/com/unciv/app/CustomSaveLocationHelperAndroid.kt b/android/src/com/unciv/app/CustomSaveLocationHelperAndroid.kt new file mode 100644 index 00000000..4eb2d69c --- /dev/null +++ b/android/src/com/unciv/app/CustomSaveLocationHelperAndroid.kt @@ -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() + + 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() +) diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 8205b290..8ee84b92 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -13,7 +13,10 @@ import com.unciv.models.metadata.GameSettings import com.unciv.models.ruleset.RulesetCache import com.unciv.models.translations.Translations 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 java.util.* import kotlin.concurrent.thread @@ -28,6 +31,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { val cancelDiscordEvent = parameters.cancelDiscordEvent val fontImplementation = parameters.fontImplementation val consoleMode = parameters.consoleMode + val customSaveLocationHelper = parameters.customSaveLocationHelper lateinit var gameInfo: GameInfo fun isGameInfoInitialized() = this::gameInfo.isInitialized @@ -69,6 +73,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { Current = this + GameSaver.customSaveLocationHelper = customSaveLocationHelper // If this takes too long players, especially with older phones, get ANR problems. // Whatever needs graphics needs to be done on the main thread, // So it's basically a long set of deferred actions. diff --git a/core/src/com/unciv/UncivGameParameters.kt b/core/src/com/unciv/UncivGameParameters.kt index 6e7f85be..8bb29f41 100644 --- a/core/src/com/unciv/UncivGameParameters.kt +++ b/core/src/com/unciv/UncivGameParameters.kt @@ -1,5 +1,6 @@ package com.unciv +import com.unciv.logic.CustomSaveLocationHelper import com.unciv.ui.utils.CrashReportSender import com.unciv.ui.utils.NativeFontImplementation @@ -8,5 +9,6 @@ class UncivGameParameters(val version: String, val exitEvent: (()->Unit)? = null, val cancelDiscordEvent: (()->Unit)? = null, val fontImplementation: NativeFontImplementation? = null, - val consoleMode: Boolean = false) { + val consoleMode: Boolean = false, + val customSaveLocationHelper: CustomSaveLocationHelper? = null) { } \ No newline at end of file diff --git a/core/src/com/unciv/logic/CustomSaveLocationHelper.kt b/core/src/com/unciv/logic/CustomSaveLocationHelper.kt new file mode 100644 index 00000000..a60b7141 --- /dev/null +++ b/core/src/com/unciv/logic/CustomSaveLocationHelper.kt @@ -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) +} diff --git a/core/src/com/unciv/logic/GameInfo.kt b/core/src/com/unciv/logic/GameInfo.kt index 7ad364d8..47b9006b 100644 --- a/core/src/com/unciv/logic/GameInfo.kt +++ b/core/src/com/unciv/logic/GameInfo.kt @@ -35,6 +35,8 @@ class GameInfo { var oneMoreTurnMode=false var currentPlayer="" var gameId = UUID.randomUUID().toString() // random string + @Volatile + var customSaveLocation: String? = null /** Simulate until any player wins, * or turns exceeds indicated number @@ -55,6 +57,7 @@ class GameInfo { toReturn.gameParameters = gameParameters toReturn.gameId = gameId toReturn.oneMoreTurnMode = oneMoreTurnMode + toReturn.customSaveLocation = customSaveLocation return toReturn } diff --git a/core/src/com/unciv/logic/GameSaver.kt b/core/src/com/unciv/logic/GameSaver.kt index 799af4fb..164a3b02 100644 --- a/core/src/com/unciv/logic/GameSaver.kt +++ b/core/src/com/unciv/logic/GameSaver.kt @@ -14,6 +14,8 @@ object GameSaver { private const val saveFilesFolder = "SaveFiles" private const val multiplayerFilesFolder = "MultiplayerGames" 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 * 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() } - fun saveGame(game: GameInfo, GameName: String, multiplayer: Boolean = false) { - json().toJson(game,getSave(GameName, multiplayer)) + fun saveGame(game: GameInfo, GameName: String, multiplayer: Boolean = false, forcePrompt: Boolean = false, saveCompletionCallback: ((Exception?) -> Unit)? = null) { + 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) = @@ -50,7 +63,15 @@ object GameSaver { 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) game.setTransients() return game @@ -107,14 +128,26 @@ object GameSaver { } 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") // keep auto-saves for the last 10 turns for debugging purposes val newAutosaveFilename = saveFilesFolder + File.separator + "Autosave-${gameInfo.currentPlayer}-${gameInfo.turns}" getSave("Autosave").copyTo(Gdx.files.local(newAutosaveFilename)) - fun getAutosaves(): Sequence { return getSaves().filter { it.name().startsWith("Autosave") } } - while(getAutosaves().count()>10){ + fun getAutosaves(): Sequence { + return getSaves().filter { it.name().startsWith("Autosave") } + } + while (getAutosaves().count() > 10) { val saveToDelete = getAutosaves().minBy { it.lastModified() }!! deleteSave(saveToDelete.name()) } diff --git a/core/src/com/unciv/ui/saves/LoadGameScreen.kt b/core/src/com/unciv/ui/saves/LoadGameScreen.kt index b0ec731a..b59e1f17 100644 --- a/core/src/com/unciv/ui/saves/LoadGameScreen.kt +++ b/core/src/com/unciv/ui/saves/LoadGameScreen.kt @@ -13,6 +13,7 @@ import com.unciv.ui.pickerscreens.PickerScreen import com.unciv.ui.utils.* import java.text.SimpleDateFormat import java.util.* +import java.util.concurrent.CancellationException import kotlin.concurrent.thread import com.unciv.ui.utils.AutoScrollPane as ScrollPane @@ -78,6 +79,20 @@ class LoadGameScreen(previousScreen:CameraStageBaseScreen) : PickerScreen() { } } 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() deleteSaveButton.onClick { diff --git a/core/src/com/unciv/ui/saves/SaveGameScreen.kt b/core/src/com/unciv/ui/saves/SaveGameScreen.kt index 966e4f21..ac66ef1a 100644 --- a/core/src/com/unciv/ui/saves/SaveGameScreen.kt +++ b/core/src/com/unciv/ui/saves/SaveGameScreen.kt @@ -1,6 +1,7 @@ package com.unciv.ui.saves 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.Table 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.ui.pickerscreens.PickerScreen import com.unciv.ui.utils.* +import java.util.concurrent.CancellationException import kotlin.concurrent.thread import com.unciv.ui.utils.AutoScrollPane as ScrollPane @@ -40,6 +42,29 @@ class SaveGameScreen : PickerScreen() { Gdx.app.clipboard.contents = base64Gzip } 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) @@ -56,8 +81,9 @@ class SaveGameScreen : PickerScreen() { rightSideButton.onClick { rightSideButton.setText("Saving...".tr()) thread(name = "SaveGame") { - GameSaver.saveGame(UncivGame.Current.gameInfo, textField.text) - Gdx.app.postRunnable { UncivGame.Current.setWorldScreen() } + GameSaver.saveGame(UncivGame.Current.gameInfo, textField.text) { + Gdx.app.postRunnable { UncivGame.Current.setWorldScreen() } + } } } rightSideButton.enable() diff --git a/desktop/src/com/unciv/app/desktop/CustomSaveLocationHelperDesktop.kt b/desktop/src/com/unciv/app/desktop/CustomSaveLocationHelperDesktop.kt new file mode 100644 index 00000000..4d7a8e26 --- /dev/null +++ b/desktop/src/com/unciv/app/desktop/CustomSaveLocationHelperDesktop.kt @@ -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) + } +} \ No newline at end of file diff --git a/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt b/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt index 274e653b..65582db4 100644 --- a/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt +++ b/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt @@ -47,7 +47,8 @@ internal object DesktopLauncher { versionFromJar, exitEvent = { exitProcess(0) }, cancelDiscordEvent = { discordTimer?.cancel() }, - fontImplementation = NativeFontDesktop(ORIGINAL_FONT_SIZE.toInt()) + fontImplementation = NativeFontDesktop(ORIGINAL_FONT_SIZE.toInt()), + customSaveLocationHelper = CustomSaveLocationHelperDesktop() ) val game = UncivGame ( desktopParameters )