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:
Billy Brawner 2020-09-20 13:22:07 -07:00 committed by GitHub
parent fded66b523
commit 205b5ccfea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 362 additions and 11 deletions

View file

@ -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)
}
} }

View 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()
)

View file

@ -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.

View file

@ -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) {
} }

View 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)
}

View file

@ -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
} }

View file

@ -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())
} }

View file

@ -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 {

View file

@ -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()

View file

@ -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)
}
}

View file

@ -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 )