Spruced up ModManagementScreen - phase 1 (#3983)

* Spruced up ModManagementScreen - phase 1

* Spruced up ModManagementScreen - phase 1 - patch1
This commit is contained in:
SomeTroglodyte 2021-06-01 14:21:31 +02:00 committed by GitHub
parent 205f479ffe
commit 9ed73d0d3f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 729 additions and 236 deletions

View File

@ -940,6 +940,7 @@ Invalid ID! =
Mods =
Download [modName] =
Update [modName] =
Could not download mod list =
Download mod from URL =
Download =
@ -956,6 +957,9 @@ Disable as permanent visual mod =
Installed =
Downloaded! =
Could not download mod =
Online query result is incomplete =
No description provided =
[stargazers]✯ =
# Uniques that are relevant to more than one type of game object

View File

@ -34,6 +34,8 @@ class ModOptions {
var lastUpdated = ""
var modUrl = ""
var author = ""
var modSize = 0
}
class Ruleset {

View File

@ -1,12 +1,11 @@
package com.unciv.ui.pickerscreens
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextArea
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.ui.*
import com.badlogic.gdx.utils.Align
import com.badlogic.gdx.utils.Json
import com.unciv.JsonParser
@ -16,46 +15,137 @@ import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.translations.tr
import com.unciv.ui.utils.*
import com.unciv.ui.utils.UncivDateFormat.formatDate
import com.unciv.ui.utils.UncivDateFormat.parseDate
import com.unciv.ui.worldscreen.mainmenu.Github
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.HashMap
import kotlin.concurrent.thread
/**
* The Mod Management Screen - called only from [MainMenuScreen]
*/
// All picker screens auto-wrap the top table in a ScrollPane.
// Since we want the different parts to scroll separately, we disable the default ScrollPane, which would scroll everything at once.
class ModManagementScreen: PickerScreen(disableScroll = true) {
val modTable = Table().apply { defaults().pad(10f) }
val downloadTable = Table().apply { defaults().pad(10f) }
val modActionTable = Table().apply { defaults().pad(10f) }
private val modTable = Table().apply { defaults().pad(10f) }
private val scrollInstalledMods = ScrollPane(modTable)
private val downloadTable = Table().apply { defaults().pad(10f) }
private val scrollOnlineMods = ScrollPane(downloadTable)
private val modActionTable = Table().apply { defaults().pad(10f) }
val amountPerPage = 30
var lastSelectedButton: TextButton? = null
val modDescriptions: HashMap<String, String> = hashMapOf()
private var lastSelectedButton: Button? = null
private var lastSyncMarkedButton: Button? = null
private var selectedModName = ""
private var selectedAuthor = ""
// keep running count of mods fetched from online search for comparison to total count as reported by GitHub
private var downloadModCount = 0
// Description data from installed mods and online search
private val modDescriptionsInstalled: HashMap<String, String> = hashMapOf()
private val modDescriptionsOnline: HashMap<String, String> = hashMapOf()
private fun showModDescription(modName: String) {
val online = modDescriptionsOnline[modName] ?: ""
val installed = modDescriptionsInstalled[modName] ?: ""
val separator = if(online.isEmpty() || installed.isEmpty()) "" else "\n"
descriptionLabel.setText(online + separator + installed)
}
// Enable syncing entries in 'installed' and 'repo search ScrollPanes
private class ScrollToEntry(val y: Float, val height: Float, val button: Button)
private val installedScrollIndex = HashMap<String,ScrollToEntry>(30)
private val onlineScrollIndex = HashMap<String,ScrollToEntry>(30)
private var onlineScrollCurrentY = -1f
// cleanup - background processing needs to be stopped on exit and memory freed
private var runningSearchThread: Thread? = null
private var stopBackgroundTasks = false
override fun dispose() {
// make sure the worker threads will not continue trying their time-intensive job
runningSearchThread?.interrupt()
stopBackgroundTasks = true
super.dispose()
}
/** Helper class keeps references to decoration images of installed mods to enable dynamic visibility
* (actually we do not use isVisible but refill a container selectively which allows the aggregate height to adapt and the set to center vertically)
* @param container the table containing the indicators (one per mod, narrow, arranges up to three indicators vertically)
* @param visualImage image indicating _enabled as permanent visual mod_
* @param updatedImage image indicating _online mod has been updated_
*/
private class ModStateImages (
val container: Table,
isVisual: Boolean = false,
isUpdated: Boolean = false,
val visualImage: Image = ImageGetter.getImage("UnitPromotionIcons/Scouting"),
val updatedImage: Image = ImageGetter.getImage("OtherIcons/Mods")
) {
// mad but it's really initializing with the primary constructor parameter and not calling update()
var isVisual: Boolean = isVisual
set(value) { if(field!=value) { field = value; update() } }
var isUpdated: Boolean = isUpdated
set(value) { if(field!=value) { field = value; update() } }
private val spacer = Table().apply { width = 20f; height = 0f }
fun update() {
container.run {
clear()
if (isVisual) add(visualImage).row()
if (isUpdated) add(updatedImage).row()
if (!isVisual && !isUpdated) add(spacer)
pack()
}
}
}
private val modStateImages = HashMap<String,ModStateImages>(30)
init {
setDefaultCloseAction(MainMenuScreen())
refreshModTable()
refreshInstalledModTable()
topTable.add("Current mods".toLabel()).padRight(35f) // 35 = 10 default pad + 25 to compensate for permanent visual mod decoration icon
topTable.add("Downloadable mods".toLabel())
// topTable.add("Mod actions")
// Header row
topTable.add().expandX() // empty cols left and right for separator
topTable.add("Current mods".toLabel()).pad(5f).minWidth(200f).padLeft(25f)
// 30 = 5 default pad + 20 to compensate for 'permanent visual mod' decoration icon
topTable.add("Downloadable mods".toLabel()).pad(5f)
topTable.add("".toLabel()).minWidth(200f) // placeholder for "Mod actions"
topTable.add().expandX()
topTable.row()
topTable.add(ScrollPane(modTable)).pad(10f)
// horizontal separator looking like the SplitPane handle
val separator = Table(skin)
separator.background = skin.get("default-vertical", SplitPane.SplitPaneStyle::class.java).handle
topTable.add(separator).minHeight(3f).fillX().colspan(5).row()
downloadTable.add(getDownloadButton()).row()
tryDownloadPage(1)
topTable.add(ScrollPane(downloadTable))
// main row containing the three 'blocks' installed, online and information
topTable.add() // skip empty first column
topTable.add(scrollInstalledMods)
reloadOnlineMods()
topTable.add(scrollOnlineMods)
topTable.add(modActionTable)
}
fun tryDownloadPage(pageNum: Int) {
thread {
private fun reloadOnlineMods() {
onlineScrollCurrentY = -1f
downloadTable.clear()
onlineScrollIndex.clear()
downloadTable.add(getDownloadFromUrlButton()).padBottom(15f).row()
downloadTable.add("...".toLabel()).row()
tryDownloadPage(1)
}
/** background worker: querying GitHub for Mods (repos with 'unciv-mod' in its topics)
*
* calls itself for the next page of search results
*/
private fun tryDownloadPage(pageNum: Int) {
runningSearchThread = thread(name="GitHubSearch") {
val repoSearch: Github.RepoSearch
try {
repoSearch = Github.tryGetGithubReposWithTopic(amountPerPage, pageNum)!!
@ -63,83 +153,158 @@ class ModManagementScreen: PickerScreen(disableScroll = true) {
Gdx.app.postRunnable {
ToastPopup("Could not download mod list", this)
}
runningSearchThread = null
return@thread
}
Gdx.app.postRunnable {
// clear and hide last cell if it is the "..." indicator
val lastCell = downloadTable.cells.lastOrNull()
if (lastCell != null && lastCell.actor is Label && (lastCell.actor as Label).text.toString() == "...") {
lastCell.setActor<Actor>(null)
lastCell.pad(0f)
}
for (repo in repoSearch.items) {
if (stopBackgroundTasks) return@postRunnable
repo.name = repo.name.replace('-', ' ')
modDescriptions[repo.name] = repo.description + "\n" + "[${repo.stargazers_count}]✯".tr() +
if (modDescriptions.contains(repo.name))
"\n" + modDescriptions[repo.name]
else ""
modDescriptionsOnline[repo.name] =
(repo.description ?: "-{No description provided}-".tr()) +
"\n" + "[${repo.stargazers_count}]✯".tr()
var downloadButtonText = repo.name
val existingMod = RulesetCache.values.firstOrNull { it.name == repo.name }
if (existingMod != null) {
if (existingMod.modOptions.lastUpdated != "" && existingMod.modOptions.lastUpdated != repo.updated_at)
if (existingMod.modOptions.lastUpdated != "" && existingMod.modOptions.lastUpdated != repo.updated_at) {
downloadButtonText += " - {Updated}"
}
val downloadButton = downloadButtonText.toTextButton()
downloadButton.onClick {
lastSelectedButton?.color = Color.WHITE
downloadButton.color = Color.BLUE
lastSelectedButton = downloadButton
descriptionLabel.setText(modDescriptions[repo.name])
removeRightSideClickListeners()
rightSideButton.enable()
rightSideButton.setText("Download [${repo.name}]".tr())
rightSideButton.onClick {
rightSideButton.setText("Downloading...".tr())
rightSideButton.disable()
downloadMod(repo) {
rightSideButton.setText("Downloaded!".tr())
}
modStateImages[repo.name]?.isUpdated = true
}
if (existingMod.modOptions.author.isEmpty()) {
rewriteModOptions(repo, Gdx.files.local("mods").child(repo.name))
existingMod.modOptions.author = repo.owner.login
existingMod.modOptions.modSize = repo.size
}
}
val downloadButton = downloadButtonText.toTextButton()
downloadButton.onClick { onlineButtonAction(repo, downloadButton) }
modActionTable.clear()
addModInfoToActionTable(repo.html_url, repo.updated_at)
}
downloadTable.add(downloadButton).row()
val cell = downloadTable.add(downloadButton)
downloadTable.row()
if (onlineScrollCurrentY < 0f) onlineScrollCurrentY = cell.padTop
onlineScrollIndex[repo.name] = ScrollToEntry(onlineScrollCurrentY, cell.prefHeight, downloadButton)
onlineScrollCurrentY += cell.padBottom + cell.prefHeight + cell.padTop
downloadModCount++
}
if (repoSearch.items.size == amountPerPage) {
val nextPageButton = "Next page".toTextButton()
nextPageButton.onClick {
nextPageButton.remove()
tryDownloadPage(pageNum + 1)
// Now the tasks after the 'page' of search results has been fully processed
if (repoSearch.items.size < amountPerPage) {
// The search has reached the last page!
// Check: due to time passing between github calls it is not impossible we get a mod twice
val checkedMods: MutableSet<String> = mutableSetOf()
val duplicates: MutableList<Cell<Actor>> = mutableListOf()
downloadTable.cells.forEach {
cell->
cell.actor?.name?.apply {
if (checkedMods.contains(this)) {
duplicates.add(cell)
} else checkedMods.add(this)
}
}
downloadTable.add(nextPageButton).row()
duplicates.forEach {
it.setActor(null)
it.pad(0f) // the cell itself cannot be removed so stop it occupying height
}
downloadModCount -= duplicates.size
// Check: It is also not impossible we missed a mod - just inform user
if (repoSearch.total_count > downloadModCount || repoSearch.incomplete_results) {
val retryLabel = "Online query result is incomplete".toLabel(Color.RED)
retryLabel.touchable = Touchable.enabled
retryLabel.onClick { reloadOnlineMods() }
downloadTable.add(retryLabel)
}
} else {
// the page was full so there may be more pages.
// indicate that search will be continued
downloadTable.add("...".toLabel()).row()
}
downloadTable.pack()
// Shouldn't actor.parent.actor = actor be a no-op? No, it has side effects we need.
// See [commit for #3317](https://github.com/yairm210/Unciv/commit/315a55f972b8defe22e76d4a2d811c6e6b607e57)
(downloadTable.parent as ScrollPane).actor = downloadTable
// continue search unless last page was reached
if (repoSearch.items.size >= amountPerPage && !stopBackgroundTasks)
tryDownloadPage(pageNum + 1)
}
runningSearchThread = null
}
}
fun addModInfoToActionTable(repoUrl: String, updatedAt: String) {
private fun syncOnlineSelected(name: String, button: Button) {
syncSelected(name, button, installedScrollIndex, scrollInstalledMods)
}
private fun syncInstalledSelected(name: String, button: Button) {
syncSelected(name, button, onlineScrollIndex, scrollOnlineMods)
}
private fun syncSelected(name: String, button: Button, index: HashMap<String, ScrollToEntry>, scroll: ScrollPane) {
// manage selection color for user selection
lastSelectedButton?.color = Color.WHITE
button.color = Color.BLUE
lastSelectedButton = button
if (lastSelectedButton == lastSyncMarkedButton) lastSyncMarkedButton = null
// look for sync-able same mod in other list
val pos = index[name] ?: return
// scroll into view
scroll.scrollY = (pos.y + (pos.height - scroll.height) / 2).coerceIn(0f, scroll.maxY)
// and color it so it's easier to find. ROYAL and SLATE too dark.
lastSyncMarkedButton?.color = Color.WHITE
pos.button.color = Color.valueOf("7499ab") // about halfway between royal and sky
lastSyncMarkedButton = pos.button
}
/** Recreate the information part of the right-hand column
* @param repo: the repository instance as received from the GitHub api
*/
private fun addModInfoToActionTable(repo: Github.Repo) {
addModInfoToActionTable(repo.name, repo.html_url, repo.updated_at, repo.owner.login, repo.size)
}
/** Recreate the information part of the right-hand column
* @param modName: The mod name (name from the RuleSet)
* @param modOptions: The ModOptions as enriched by us with GitHub metadata when originally downloaded
*/
private fun addModInfoToActionTable(modName: String, modOptions: ModOptions) {
addModInfoToActionTable(modName, modOptions.modUrl, modOptions.lastUpdated, modOptions.author, modOptions.modSize)
}
private fun addModInfoToActionTable(modName: String, repoUrl: String, updatedAt: String, author: String, modSize: Int) {
// remember selected mod - for now needed only to display a background-fetched image while the user is watching
selectedModName = modName
selectedAuthor = author
// Display metadata
if (author.isNotEmpty())
modActionTable.add("Author: [$author]".toLabel()).row()
if (modSize > 0)
modActionTable.add("Size: [$modSize] kB".toLabel()).padBottom(15f).row()
// offer link to open the repo itself in a browser
if (repoUrl != "") {
modActionTable.add("Open Github page".toTextButton().onClick {
Gdx.net.openURI(repoUrl)
}).row()
}
if (updatedAt != "") {
// Everything under java.time is from Java 8 onwards, meaning older phones that use Java 7 won't be able to handle it :/
// So we're forced to use ancient Java 6 classes instead of the newer and nicer LocalDateTime.parse :(
// Direct solution from https://stackoverflow.com/questions/2201925/converting-iso-8601-compliant-string-to-java-util-date
val df2 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) // example: 2021-04-11T14:43:33Z
val date = df2.parse(updatedAt)
val updateString = "{Updated}: " +DateFormat.getDateInstance(DateFormat.SHORT).format(date)
modActionTable.add(updateString.toLabel())
// display "updated" date
if (updatedAt.isNotEmpty()) {
val date = updatedAt.parseDate()
val updateString = "{Updated}: " + date.formatDate()
modActionTable.add(updateString.toLabel()).row()
}
}
fun getDownloadButton(): TextButton {
/** Create the special "Download from URL" button */
private fun getDownloadFromUrlButton(): TextButton {
val downloadButton = "Download mod from URL".toTextButton()
downloadButton.onClick {
val popup = Popup(this)
@ -158,22 +323,42 @@ class ModManagementScreen: PickerScreen(disableScroll = true) {
return downloadButton
}
fun downloadMod(repo: Github.Repo, postAction: () -> Unit = {}) {
thread { // to avoid ANRs - we've learnt our lesson from previous download-related actions
/** Used as onClick handler for the online Mod list buttons */
private fun onlineButtonAction(repo: Github.Repo, button: Button) {
syncOnlineSelected(repo.name, button)
showModDescription(repo.name)
removeRightSideClickListeners()
rightSideButton.enable()
val label = if (modStateImages[repo.name]?.isUpdated == true)
"Update [${repo.name}]"
else "Download [${repo.name}]"
rightSideButton.setText(label.tr())
rightSideButton.onClick {
rightSideButton.setText("Downloading...".tr())
rightSideButton.disable()
downloadMod(repo) {
rightSideButton.setText("Downloaded!".tr())
}
}
modActionTable.clear()
addModInfoToActionTable(repo)
}
/** Download and install a mod in the background, called from the right-bottom button */
private fun downloadMod(repo: Github.Repo, postAction: () -> Unit = {}) {
thread(name="DownloadMod") { // to avoid ANRs - we've learnt our lesson from previous download-related actions
try {
val modFolder = Github.downloadAndExtract(repo.html_url, repo.default_branch,
Gdx.files.local("mods"))
if (modFolder == null) return@thread
// rewrite modOptions file
val modOptionsFile = modFolder.child("jsons/ModOptions.json")
val modOptions = if (modOptionsFile.exists()) JsonParser().getFromJson(ModOptions::class.java, modOptionsFile) else ModOptions()
modOptions.modUrl = repo.html_url
modOptions.lastUpdated = repo.updated_at
Json().toJson(modOptions, modOptionsFile)
Gdx.files.local("mods"))
?: return@thread
rewriteModOptions(repo, modFolder)
Gdx.app.postRunnable {
ToastPopup("Downloaded!", this)
RulesetCache.loadRulesets()
refreshModTable()
refreshInstalledModTable()
showModDescription(repo.name)
unMarkUpdatedMod(repo.name)
}
} catch (ex: Exception) {
Gdx.app.postRunnable {
@ -185,67 +370,125 @@ class ModManagementScreen: PickerScreen(disableScroll = true) {
}
}
fun refreshModActions(mod: Ruleset, decorationImage: Actor) {
/** Rewrite modOptions file for a mod we just installed to include metadata we got from the GitHub api
*
* (called on background thread)
*/
private fun rewriteModOptions(repo: Github.Repo, modFolder: FileHandle) {
val modOptionsFile = modFolder.child("jsons/ModOptions.json")
val modOptions = if (modOptionsFile.exists()) JsonParser().getFromJson(ModOptions::class.java, modOptionsFile) else ModOptions()
modOptions.modUrl = repo.html_url
modOptions.lastUpdated = repo.updated_at
modOptions.author = repo.owner.login
modOptions.modSize = repo.size
Json().toJson(modOptions, modOptionsFile)
}
/** Remove the visual indicators for an 'updated' mod after re-downloading it.
* (" - Updated" on the button text in the online mod list and the icon beside the installed mod's button)
* It should be up to date now (unless the repo's date is in the future relative to system time)
*
* (called under postRunnable posted by background thread)
*/
private fun unMarkUpdatedMod(name: String) {
modStateImages[name]?.isUpdated = false
val button = (onlineScrollIndex[name]?.button as? TextButton) ?: return
button.setText(name)
}
/** Rebuild the right-hand column for clicks on installed mods
* Display single mod metadata, offer additional actions (delete is elsewhere)
*/
private fun refreshModActions(mod: Ruleset) {
modActionTable.clear()
// show mod information first
addModInfoToActionTable(mod.name, mod.modOptions)
// offer 'permanent visual mod' toggle
val visualMods = game.settings.visualMods
if (!visualMods.contains(mod.name)) {
decorationImage.isVisible = false
val isVisual = visualMods.contains(mod.name)
modStateImages[mod.name]?.isVisual = isVisual
if (!isVisual) {
modActionTable.add("Enable as permanent visual mod".toTextButton().onClick {
visualMods.add(mod.name)
game.settings.save()
ImageGetter.setNewRuleset(ImageGetter.ruleset)
refreshModActions(mod, decorationImage)
refreshModActions(mod)
})
} else {
decorationImage.isVisible = true
modActionTable.add("Disable as permanent visual mod".toTextButton().onClick {
visualMods.remove(mod.name)
game.settings.save()
ImageGetter.setNewRuleset(ImageGetter.ruleset)
refreshModActions(mod, decorationImage)
refreshModActions(mod)
})
}
modActionTable.row()
addModInfoToActionTable(mod.modOptions.modUrl, mod.modOptions.lastUpdated)
}
fun refreshModTable() {
/** Rebuild the left-hand column containing all installed mods */
private fun refreshInstalledModTable() {
modTable.clear()
val currentMods = RulesetCache.values.filter { it.name != "" }
installedScrollIndex.clear()
var currentY = -1f
val currentMods = RulesetCache.values.asSequence().filter { it.name != "" }.sortedBy { it.name }
for (mod in currentMods) {
val summary = mod.getSummary()
modDescriptions[mod.name] = "Installed".tr() +
modDescriptionsInstalled[mod.name] = "Installed".tr() +
(if (summary.isEmpty()) "" else ": $summary")
val decorationImage = ImageGetter.getPromotionIcon("Scouting", 25f)
var imageMgr = modStateImages[mod.name]
val decorationTable =
if (imageMgr != null) imageMgr.container
else {
val table = Table().apply { defaults().size(20f).align(Align.topLeft) }
imageMgr = ModStateImages(table, isVisual = mod.name in game.settings.visualMods)
modStateImages[mod.name] = imageMgr
table
}
imageMgr.update() // rebuilds decorationTable content
val button = mod.name.toTextButton()
button.onClick {
lastSelectedButton?.color = Color.WHITE
button.color = Color.BLUE
lastSelectedButton = button
refreshModActions(mod, decorationImage)
syncInstalledSelected(mod.name, button)
refreshModActions(mod)
rightSideButton.setText("Delete [${mod.name}]".tr())
rightSideButton.enable()
descriptionLabel.setText(modDescriptions[mod.name])
rightSideButton.isEnabled = true
showModDescription(mod.name)
removeRightSideClickListeners()
rightSideButton.onClick {
YesNoPopup("Are you SURE you want to delete this mod?",
{ deleteMod(mod) }, this).open()
rightSideButton.isEnabled = false
YesNoPopup(
question = "Are you SURE you want to delete this mod?",
action = {
deleteMod(mod)
rightSideButton.setText("[${mod.name}] was deleted.".tr())
},
screen = this,
restoreDefault = { rightSideButton.isEnabled = true }
).open()
}
}
val decoratedButton = Table()
decoratedButton.add(button)
decorationImage.isVisible = game.settings.visualMods.contains(mod.name)
decoratedButton.add(decorationImage).align(Align.topLeft)
modTable.add(decoratedButton).row()
decoratedButton.add(decorationTable).align(Align.center+Align.left)
val cell = modTable.add(decoratedButton)
modTable.row()
if (currentY < 0f) currentY = cell.padTop
installedScrollIndex[mod.name] = ScrollToEntry(currentY, cell.prefHeight, button)
currentY += cell.padBottom + cell.prefHeight + cell.padTop
}
}
fun deleteMod(mod: Ruleset) {
/** Delete a Mod, refresh ruleset cache and update installed mod table */
private fun deleteMod(mod: Ruleset) {
val modFileHandle = Gdx.files.local("mods").child(mod.name)
if (modFileHandle.isDirectory) modFileHandle.deleteDirectory()
else modFileHandle.delete()
else modFileHandle.delete() // This should never happen
RulesetCache.loadRulesets()
refreshModTable()
modStateImages.remove(mod.name)
refreshInstalledModTable()
}
}

View File

@ -14,7 +14,7 @@ import com.unciv.logic.UncivShowableException
import com.unciv.models.translations.tr
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.utils.*
import java.text.SimpleDateFormat
import com.unciv.ui.utils.UncivDateFormat.formatDate
import java.util.*
import java.util.concurrent.CancellationException
import kotlin.concurrent.thread
@ -180,8 +180,7 @@ class LoadGameScreen(previousScreen:CameraStageBaseScreen) : PickerScreen(disabl
val savedAt = Date(save.lastModified())
var textToSet = save.name() +
"\n${"Saved at".tr()}: " + SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(savedAt)
var textToSet = save.name() + "\n${"Saved at".tr()}: " + savedAt.formatDate()
thread { // Even loading the game to get its metadata can take a long time on older phones
try {
val game = GameSaver.loadGamePreviewFromFile(save)

View File

@ -10,6 +10,8 @@ import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener
import com.unciv.models.UncivSound
import com.unciv.models.translations.tr
import java.text.SimpleDateFormat
import java.util.*
import kotlin.concurrent.thread
import kotlin.random.Random
@ -102,8 +104,7 @@ fun Table.addSeparator(): Cell<Image> {
fun Table.addSeparatorVertical(): Cell<Image> {
val image = ImageGetter.getWhiteDot()
val cell = add(image).width(2f).fillY()
return cell
return add(image).width(2f).fillY()
}
fun <T : Actor> Table.addCell(actor: T): Table {
@ -200,3 +201,28 @@ fun <T> List<T>.randomWeighted(weights: List<Float>, random: Random = Random): T
}
return this.last()
}
/**
* Standardize date formatting so dates are presented in a consistent style and all decisions
* to change date handling are encapsulated here
*/
object UncivDateFormat {
private val standardFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
/** Format a date to ISO format with minutes */
fun Date.formatDate(): String = standardFormat.format(this)
// Previously also used:
//val updateString = "{Updated}: " +DateFormat.getDateInstance(DateFormat.SHORT).format(date)
// Everything under java.time is from Java 8 onwards, meaning older phones that use Java 7 won't be able to handle it :/
// So we're forced to use ancient Java 6 classes instead of the newer and nicer LocalDateTime.parse :(
// Direct solution from https://stackoverflow.com/questions/2201925/converting-iso-8601-compliant-string-to-java-util-date
@Suppress("SpellCheckingInspection")
private val utcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
/** Parse an UTC date as passed by online API's
* example: `"2021-04-11T14:43:33Z".parseDate()`
*/
fun String.parseDate(): Date = utcFormat.parse(this)
}

View File

@ -1,6 +1,5 @@
package com.unciv.ui.worldscreen.mainmenu
import com.badlogic.gdx.files.FileHandle
import com.unciv.logic.GameInfo
import com.unciv.logic.GameSaver
import com.unciv.ui.saves.Gzip
@ -8,8 +7,6 @@ import java.io.*
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.Charset
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
object DropBox {
@ -19,6 +16,7 @@ object DropBox {
with(URL(url).openConnection() as HttpURLConnection) {
requestMethod = "POST" // default is GET
@Suppress("SpellCheckingInspection")
setRequestProperty("Authorization", "Bearer LTdBbopPUQ0AAAAAAAACxh4_Qd1eVMM7IBK3ULV3BgxzWZDMfhmgFbuUNF_rXQWb")
if (dropboxApiArg != "") setRequestProperty("Dropbox-API-Arg", dropboxApiArg)
@ -76,8 +74,7 @@ object DropBox {
fun downloadFileAsString(fileName: String): String {
val inputStream = downloadFile(fileName)
val text = BufferedReader(InputStreamReader(inputStream)).readText()
return text
return BufferedReader(InputStreamReader(inputStream)).readText()
}
fun uploadFile(fileName: String, data: String, overwrite: Boolean = false){
@ -98,13 +95,14 @@ object DropBox {
// return BufferedReader(InputStreamReader(result)).readText()
// }
@Suppress("PropertyName")
class FolderList{
var entries = ArrayList<FolderListEntry>()
var cursor = ""
var has_more = false
}
@Suppress("PropertyName")
class FolderListEntry{
var name=""
var path_display=""
@ -128,127 +126,10 @@ class OnlineMultiplayer {
/**
* WARNING!
* Does not initialize transitive GameInfo data.
* It is therefore stateless and save to call for Multiplayer Turn Notifier, unlike tryDownloadGame().
* It is therefore stateless and safe to call for Multiplayer Turn Notifier, unlike tryDownloadGame().
*/
fun tryDownloadGameUninitialized(gameId: String): GameInfo {
val zippedGameInfo = DropBox.downloadFileAsString(getGameLocation(gameId))
return GameSaver.gameInfoFromStringWithoutTransients(Gzip.unzip(zippedGameInfo))
}
}
object Github {
// Consider merging this with the Dropbox function
fun download(url: String, action: (HttpURLConnection) -> Unit = {}): InputStream? {
with(URL(url).openConnection() as HttpURLConnection)
{
action(this)
try {
return inputStream
} catch (ex: Exception) {
println(ex.message)
val reader = BufferedReader(InputStreamReader(errorStream))
println(reader.readText())
return null
}
}
}
// This took a long time to get just right, so if you're changing this, TEST IT THOROUGHLY on both Desktop and Phone
fun downloadAndExtract(gitRepoUrl:String, defaultBranch:String, folderFileHandle:FileHandle): FileHandle? {
val zipUrl = "$gitRepoUrl/archive/$defaultBranch.zip"
val inputStream = download(zipUrl)
if (inputStream == null) return null
val tempZipFileHandle = folderFileHandle.child("tempZip.zip")
tempZipFileHandle.write(inputStream, false)
val unzipDestination = tempZipFileHandle.sibling("tempZip") // folder, not file
Zip.extractFolder(tempZipFileHandle, unzipDestination)
val innerFolder = unzipDestination.list().first() // tempZip/<repoName>-master/
val finalDestinationName = innerFolder.name().replace("-$defaultBranch", "").replace('-', ' ')
val finalDestination = folderFileHandle.child(finalDestinationName)
finalDestination.mkdirs() // If we don't create this as a directory, it will think this is a file and nothing will work.
for (innerFileOrFolder in innerFolder.list()) {
innerFileOrFolder.moveTo(finalDestination)
}
tempZipFileHandle.delete()
unzipDestination.deleteDirectory()
return finalDestination
}
fun tryGetGithubReposWithTopic(amountPerPage:Int, page:Int): RepoSearch? {
// Default per-page is 30 - when we get to above 100 mods, we'll need to start search-queries
val inputStream = download("https://api.github.com/search/repositories?q=topic:unciv-mod&per_page=$amountPerPage&page=$page")
if (inputStream == null) return null
return GameSaver.json().fromJson(RepoSearch::class.java, inputStream.bufferedReader().readText())
}
class RepoSearch {
var items = ArrayList<Repo>()
}
class Repo {
var name = ""
var description = ""
var stargazers_count = 0
var default_branch = ""
var html_url = ""
var updated_at = ""
}
}
object Zip {
// I went through a lot of similar answers that didn't work until I got to this gem by NeilMonday
// (with mild changes to fit the FileHandles)
// https://stackoverflow.com/questions/981578/how-to-unzip-files-recursively-in-java
fun extractFolder(zipFile: FileHandle, unzipDestination: FileHandle) {
println(zipFile)
val BUFFER = 2048
val file = zipFile.file()
val zip = ZipFile(file)
unzipDestination.mkdirs()
val zipFileEntries = zip.entries()
// Process each entry
while (zipFileEntries.hasMoreElements()) {
// grab a zip file entry
val entry = zipFileEntries.nextElement() as ZipEntry
val currentEntry = entry.name
val destFile = unzipDestination.child(currentEntry)
val destinationParent = destFile.parent()
// create the parent directory structure if needed
destinationParent.mkdirs()
if (!entry.isDirectory) {
val inputStream = BufferedInputStream(zip
.getInputStream(entry))
var currentByte: Int
// establish buffer for writing file
val data = ByteArray(BUFFER)
// write the current file to disk
val fos = FileOutputStream(destFile.file())
val dest = BufferedOutputStream(fos,
BUFFER)
// read and write until last byte is encountered
while (inputStream.read(data, 0, BUFFER).also { currentByte = it } != -1) {
dest.write(data, 0, currentByte)
}
dest.flush()
dest.close()
inputStream.close()
}
if (currentEntry.endsWith(".zip")) {
// found a zip file, try to open
extractFolder(destFile, unzipDestination)
}
}
zip.close() // Needed so we can delete the zip file later
}
}

View File

@ -0,0 +1,336 @@
package com.unciv.ui.worldscreen.mainmenu
import com.badlogic.gdx.files.FileHandle
import com.unciv.logic.GameSaver
import java.io.*
import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Utility managing Github access (except the link in WorldScreenCommunityPopup)
*
* Singleton - RateLimit is shared app-wide and has local variables, and is not tested for thread safety.
* Therefore, additional effort is required should [tryGetGithubReposWithTopic] ever be called non-sequentially.
* [download] and [downloadAndExtract] should be thread-safe as they are self-contained.
* They do not join in the [RateLimit] handling because Github doc suggests each API
* has a separate limit (and I found none for cloning via a zip).
*/
object Github {
// Consider merging this with the Dropbox function
/**
* Helper opens am url and accesses its input stream, logging errors to the console
* @param url String representing a [URL] to download.
* @param action Optional callback that will be executed between opening the connection and
* accessing its data - passes the [connection][HttpURLConnection] and allows e.g. reading the response headers.
* @return The [InputStream] if successful, `null` otherwise.
*/
fun download(url: String, action: (HttpURLConnection) -> Unit = {}): InputStream? {
with(URL(url).openConnection() as HttpURLConnection)
{
action(this)
return try {
inputStream
} catch (ex: Exception) {
println(ex.message)
val reader = BufferedReader(InputStreamReader(errorStream))
println(reader.readText())
null
}
}
}
/**
* Download a mod and extract, deleting any pre-existing version.
* @param gitRepoUrl Url of the repository as delivered by the Github search query
* @param defaultBranch Branch name as delivered by the Github search query
* @param folderFileHandle Destination handle of mods folder - also controls Android internal/external
* @author **Warning**: This took a long time to get just right, so if you're changing this, ***TEST IT THOROUGHLY*** on _both_ Desktop _and_ Phone
* @return FileHandle for the downloaded Mod's folder or null if download failed
*/
fun downloadAndExtract(
gitRepoUrl: String,
defaultBranch: String,
folderFileHandle: FileHandle
): FileHandle? {
// Initiate download - the helper returns null when it fails
val zipUrl = "$gitRepoUrl/archive/$defaultBranch.zip"
val inputStream = download(zipUrl) ?: return null
// Download to temporary zip
val tempZipFileHandle = folderFileHandle.child("tempZip.zip")
tempZipFileHandle.write(inputStream, false)
// prepare temp unpacking folder
val unzipDestination = tempZipFileHandle.sibling("tempZip") // folder, not file
// prevent mixing new content with old - hopefully there will never be cadavers of our tempZip stuff
if (unzipDestination.exists())
if (unzipDestination.isDirectory) unzipDestination.deleteDirectory() else unzipDestination.delete()
Zip.extractFolder(tempZipFileHandle, unzipDestination)
val innerFolder = unzipDestination.list().first()
// innerFolder should now be "tempZip/$repoName-$defaultBranch/" - use this to get mod name
val finalDestinationName = innerFolder.name().replace("-$defaultBranch", "").replace('-', ' ')
// finalDestinationName is now the mod name as we display it. Folder name needs to be identical.
val finalDestination = folderFileHandle.child(finalDestinationName)
// prevent mixing new content with old
var tempBackup: FileHandle? = null
if (finalDestination.exists()) {
tempBackup = finalDestination.sibling("$finalDestinationName.updating")
finalDestination.moveTo(tempBackup)
}
// Move temp unpacked content to their final place
finalDestination.mkdirs() // If we don't create this as a directory, it will think this is a file and nothing will work.
// The move will reset the last modified time (recursively, at least on Linux)
// This sort will guarantee the desktop launcher will not re-pack textures and overwrite the atlas as delivered by the mod
for (innerFileOrFolder in innerFolder.list()
.sortedBy { file -> file.extension() == "atlas" } ) {
innerFileOrFolder.moveTo(finalDestination)
}
// clean up
tempZipFileHandle.delete()
unzipDestination.deleteDirectory()
if (tempBackup != null)
if (tempBackup.isDirectory) tempBackup.deleteDirectory() else tempBackup.delete()
return finalDestination
}
/**
* Implements the ability wo work with GitHub's rate limit, recognize blocks from previous attempts, wait and retry.
*/
object RateLimit {
// https://docs.github.com/en/rest/reference/search#rate-limit
const val maxRequestsPerInterval = 10
const val intervalInMilliSeconds = 60000L
private const val maxWaitLoop = 3
private var account = 0 // used requests
private var firstRequest = 0L // timestamp window start (java epoch millisecond)
/*
Github rate limits do not use sliding windows - you (if anonymous) get one window
which starts with the first request (if a window is not already active)
and ends 60s later, and a budget of 10 requests in that window. Once it expires,
everything is forgotten and the process starts from scratch
*/
private val millis: Long
get() = System.currentTimeMillis()
/** calculate required wait in ms
* @return Estimated number of milliseconds to wait for the rate limit window to expire
*/
private fun getWaitLength()
= (firstRequest + intervalInMilliSeconds - millis)
/** Maintain and check a rate-limit
* @return **true** if rate-limited, **false** if another request is allowed
*/
private fun isLimitReached(): Boolean {
val now = millis
val elapsed = if (firstRequest == 0L) intervalInMilliSeconds else now - firstRequest
if (elapsed >= intervalInMilliSeconds) {
firstRequest = now
account = 1
return false
}
if (account >= maxRequestsPerInterval) return true
account++
return false
}
/** If rate limit in effect, sleep long enough to allow next request.
*
* @return **true** if waiting did not clear isLimitReached() (can only happen if the clock is broken),
* or the wait has been interrupted by Thread.interrupt()
* **false** if we were below the limit or slept long enough to drop out of it.
*/
fun waitForLimit(): Boolean {
var loopCount = 0
while (isLimitReached()) {
val waitLength = getWaitLength()
try {
Thread.sleep(waitLength)
} catch ( ex: InterruptedException ) {
return true
}
if (++loopCount >= maxWaitLoop) return true
}
return false
}
/** http responses should be passed to this so the actual rate limit window can be evaluated and used.
* The very first response and all 403 ones are good candidates if they can be expected to contain GitHub's rate limit headers.
*
* see: https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
*/
fun notifyHttpResponse(response: HttpURLConnection) {
if (response.responseMessage != "rate limit exceeded" && response.responseCode != 200) return
fun getHeaderLong(name: String, default: Long = 0L) =
response.headerFields[name]?.get(0)?.toLongOrNull() ?: default
val limit = getHeaderLong("X-RateLimit-Limit", maxRequestsPerInterval.toLong()).toInt()
val remaining = getHeaderLong("X-RateLimit-Remaining").toInt()
val reset = getHeaderLong("X-RateLimit-Reset")
if (limit != maxRequestsPerInterval)
println("GitHub API Limit reported via http ($limit) not equal assumed value ($maxRequestsPerInterval)")
account = maxRequestsPerInterval - remaining
if (reset == 0L) return
firstRequest = (reset + 1L) * 1000L - intervalInMilliSeconds
}
}
/**
* Query GitHub for repositories marked "unciv-mod"
* @param amountPerPage Number of search results to return for this request.
* @param page The "page" number, starting at 1.
* @return Parsed [RepoSearch] json on success, `null` on failure.
* @see <a href="https://docs.github.com/en/rest/reference/search#search-repositories">Github API doc</a>
*/
fun tryGetGithubReposWithTopic(amountPerPage:Int, page:Int): RepoSearch? {
val link = "https://api.github.com/search/repositories?q=topic:unciv-mod&sort:stars&per_page=$amountPerPage&page=$page"
var retries = 2
while (retries > 0) {
retries--
// obey rate limit
if (RateLimit.waitForLimit()) return null
// try download
val inputStream = download(link) {
if (it.responseCode == 403 || it.responseCode == 200 && page == 1 && retries == 1) {
// Pass the response headers to the rate limit handler so it can process the rate limit headers
RateLimit.notifyHttpResponse(it)
retries++ // An extra retry so the 403 is ignored in the retry count
}
} ?: continue
return GameSaver.json().fromJson(RepoSearch::class.java, inputStream.bufferedReader().readText())
}
return null
}
/**
* Parsed GitHub repo search response
* @property total_count Total number of hits for the search (ignoring paging window)
* @property incomplete_results A flag set by github to indicate search was incomplete (never seen it on)
* @property items Array of [repositories][Repo]
* @see <a href="https://docs.github.com/en/rest/reference/search#search-repositories--code-samples">Github API doc</a>
*/
@Suppress("PropertyName")
class RepoSearch {
var total_count = 0
var incomplete_results = false
var items = ArrayList<Repo>()
}
/** Part of [RepoSearch] in Github API response - one repository entry in [items][RepoSearch.items] */
@Suppress("PropertyName")
class Repo {
var name = ""
var full_name = ""
var description: String? = null
var owner = RepoOwner()
var stargazers_count = 0
var default_branch = ""
var html_url = ""
var updated_at = ""
//var pushed_at = "" // if > updated_at might indicate an update soon?
var size = 0
//var stargazers_url = ""
//var homepage: String? = null // might use instead of go to repo?
//var has_wiki = false // a wiki could mean proper documentation for the mod?
}
/** Part of [Repo] in Github API response */
@Suppress("PropertyName")
class RepoOwner {
var login = ""
var avatar_url: String? = null
}
}
/** Utility - extract Zip archives
* @see [Zip.extractFolder]
*/
object Zip {
private const val bufferSize = 2048
/**
* Extract one Zip file recursively (nested Zip files are extracted in turn).
*
* The source Zip is not deleted, but successfully extracted nested ones are.
*
* **Warning**: Extracting into a non-empty destination folder will merge contents. Existing
* files also included in the archive will be partially overwritten, when the new data is shorter
* than the old you will get _mixed contents!_
*
* @param zipFile The Zip file to extract
* @param unzipDestination The folder to extract into, preferably empty (not enforced).
*/
fun extractFolder(zipFile: FileHandle, unzipDestination: FileHandle) {
// I went through a lot of similar answers that didn't work until I got to this gem by NeilMonday
// (with mild changes to fit the FileHandles)
// https://stackoverflow.com/questions/981578/how-to-unzip-files-recursively-in-java
println("Extracting $zipFile to $unzipDestination")
// establish buffer for writing file
val data = ByteArray(bufferSize)
fun streamCopy(fromStream: InputStream, toHandle: FileHandle) {
val inputStream = BufferedInputStream(fromStream)
var currentByte: Int
// write the current file to disk
val fos = FileOutputStream(toHandle.file())
val dest = BufferedOutputStream(fos, bufferSize)
// read and write until last byte is encountered
while (inputStream.read(data, 0, bufferSize).also { currentByte = it } != -1) {
dest.write(data, 0, currentByte)
}
dest.flush()
dest.close()
inputStream.close()
}
val file = zipFile.file()
val zip = ZipFile(file)
//unzipDestination.mkdirs()
val zipFileEntries = zip.entries()
// Process each entry
while (zipFileEntries.hasMoreElements()) {
// grab a zip file entry
val entry = zipFileEntries.nextElement() as ZipEntry
val currentEntry = entry.name
val destFile = unzipDestination.child(currentEntry)
val destinationParent = destFile.parent()
// create the parent directory structure if needed
destinationParent.mkdirs()
if (!entry.isDirectory) {
streamCopy ( zip.getInputStream(entry), destFile)
}
// The new file has a current last modification time
// and not the one stored in the archive - we could:
// 'destFile.file().setLastModified(entry.time)'
// but later handling will throw these away anyway,
// and GitHub sets all timestamps to the download time.
if (currentEntry.endsWith(".zip")) {
// found a zip file, try to open
extractFolder(destFile, destinationParent)
destFile.delete()
}
}
zip.close() // Needed so we can delete the zip file later
}
}

View File

@ -57,6 +57,8 @@ internal object DesktopLauncher {
LwjglApplication(game, config)
}
// Work in Progress?
@Suppress("unused")
private fun startMultiplayerServer() {
// val games = HashMap<String, GameSetupInfo>()
val files = HashMap<String, String>()
@ -115,7 +117,7 @@ internal object DesktopLauncher {
// https://github.com/yairm210/UnCiv/issues/1340
/**
* These should be as big as possible in order to accommodate ALL the images together in one bug file.
* These should be as big as possible in order to accommodate ALL the images together in one big file.
* Why? Because the rendering function of the main screen renders all the images consecutively, and every time it needs to switch between textures,
* this causes a delay, leading to horrible lag if there are enough switches.
* The cost of this specific solution is that the entire game.png needs be be kept in-memory constantly.
@ -163,14 +165,14 @@ internal object DesktopLauncher {
private fun packImagesIfOutdated(settings: TexturePacker.Settings, input: String, output: String, packFileName: String) {
fun File.listTree(): Sequence<File> = when {
this.isFile -> sequenceOf(this)
this.isDirectory -> this.listFiles().asSequence().flatMap { it.listTree() }
this.isDirectory -> this.listFiles()!!.asSequence().flatMap { it.listTree() }
else -> sequenceOf()
}
val atlasFile = File("$output${File.separator}$packFileName.atlas")
if (atlasFile.exists() && File("$output${File.separator}$packFileName.png").exists()) {
val atlasModTime = atlasFile.lastModified()
if (!File(input).listTree().any { it.extension in listOf("png", "jpg", "jpeg") && it.lastModified() > atlasModTime }) return
if (File(input).listTree().none { it.extension in listOf("png", "jpg", "jpeg") && it.lastModified() > atlasModTime }) return
}
TexturePacker.process(settings, input, output, packFileName)