From 9ed73d0d3fb006328dd93fb6d1b466282c6e864c Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Tue, 1 Jun 2021 14:21:31 +0200 Subject: [PATCH] Spruced up ModManagementScreen - phase 1 (#3983) * Spruced up ModManagementScreen - phase 1 * Spruced up ModManagementScreen - phase 1 - patch1 --- .../jsons/translations/template.properties | 4 + core/src/com/unciv/models/ruleset/Ruleset.kt | 2 + .../ui/pickerscreens/ModManagementScreen.kt | 451 ++++++++++++++---- core/src/com/unciv/ui/saves/LoadGameScreen.kt | 5 +- .../com/unciv/ui/utils/ExtensionFunctions.kt | 30 +- .../unciv/ui/worldscreen/mainmenu/DropBox.kt | 129 +---- .../unciv/ui/worldscreen/mainmenu/GitHub.kt | 336 +++++++++++++ .../com/unciv/app/desktop/DesktopLauncher.kt | 8 +- 8 files changed, 729 insertions(+), 236 deletions(-) create mode 100644 core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 26385de0e6..0be55ba60c 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -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 diff --git a/core/src/com/unciv/models/ruleset/Ruleset.kt b/core/src/com/unciv/models/ruleset/Ruleset.kt index b2ee69979a..317bac6903 100644 --- a/core/src/com/unciv/models/ruleset/Ruleset.kt +++ b/core/src/com/unciv/models/ruleset/Ruleset.kt @@ -34,6 +34,8 @@ class ModOptions { var lastUpdated = "" var modUrl = "" + var author = "" + var modSize = 0 } class Ruleset { diff --git a/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt b/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt index 47f9442bd0..843dc85ef3 100644 --- a/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt +++ b/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt @@ -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 = 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 = hashMapOf() + private val modDescriptionsOnline: HashMap = 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(30) + private val onlineScrollIndex = HashMap(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(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(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 = mutableSetOf() + val duplicates: MutableList> = 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, 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() } } diff --git a/core/src/com/unciv/ui/saves/LoadGameScreen.kt b/core/src/com/unciv/ui/saves/LoadGameScreen.kt index 7465adb7e4..1c78d5b4ae 100644 --- a/core/src/com/unciv/ui/saves/LoadGameScreen.kt +++ b/core/src/com/unciv/ui/saves/LoadGameScreen.kt @@ -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) diff --git a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt index 33fe4e3c52..c5d46bdd18 100644 --- a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt +++ b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt @@ -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 { fun Table.addSeparatorVertical(): Cell { val image = ImageGetter.getWhiteDot() - val cell = add(image).width(2f).fillY() - return cell + return add(image).width(2f).fillY() } fun Table.addCell(actor: T): Table { @@ -200,3 +201,28 @@ fun List.randomWeighted(weights: List, 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) +} diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/DropBox.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/DropBox.kt index 341060f5b1..f3634ce067 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/DropBox.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/DropBox.kt @@ -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() 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/-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() - } - - 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 - } -} \ No newline at end of file diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt new file mode 100644 index 0000000000..eaaaf460fa --- /dev/null +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt @@ -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 Github API doc + */ + 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 Github API doc + */ + @Suppress("PropertyName") + class RepoSearch { + var total_count = 0 + var incomplete_results = false + var items = ArrayList() + } + + /** 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 + } +} diff --git a/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt b/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt index fbdbe103fe..2a4f46b9e6 100644 --- a/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt +++ b/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt @@ -57,6 +57,8 @@ internal object DesktopLauncher { LwjglApplication(game, config) } + // Work in Progress? + @Suppress("unused") private fun startMultiplayerServer() { // val games = HashMap() val files = HashMap() @@ -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 = 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)