diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index c32e0daa6..070ddc3f1 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -106,6 +106,8 @@ jobs: KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + COMMUNITY_HMAC_SECRET: ${{ secrets.COMMUNITY_HMAC_SECRET }} + COMMUNITY_API_BASE: ${{ secrets.COMMUNITY_API_BASE }} GRADLE_OPTS: -Xmx4g -XX:+UseG1GC run: | export KEYSTORE_FILE="${{ github.workspace }}/release.jks" diff --git a/.github/workflows/tag-apk-artifacts.yml b/.github/workflows/tag-apk-artifacts.yml index ba0955edb..0088b3bf4 100644 --- a/.github/workflows/tag-apk-artifacts.yml +++ b/.github/workflows/tag-apk-artifacts.yml @@ -97,6 +97,8 @@ jobs: KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + COMMUNITY_HMAC_SECRET: ${{ secrets.COMMUNITY_HMAC_SECRET }} + COMMUNITY_API_BASE: ${{ secrets.COMMUNITY_API_BASE }} GRADLE_OPTS: -Xmx4g -XX:+UseG1GC VERSION_NAME: ${{ github.ref_name }} run: | diff --git a/.gitignore b/.gitignore index d3a4ae7a1..6740b8aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,9 @@ References/ *.hprof android_sysvshm/build64/ +# Community config HMAC secret (never commit) +tools/community_hmac.secret + # local build artifacts (FEX/Proton wcp) dist/ diff --git a/app/build.gradle b/app/build.gradle index 14fecd478..c05fce5f8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -131,6 +131,22 @@ def appVersionName = providers.gradleProperty("VERSION_NAME") def coldClientVersionFile = rootProject.file("tools/gbe_fork.version") def coldClientVersion = coldClientVersionFile.exists() ? coldClientVersionFile.text.trim() : "unknown" +def communityHmacFile = rootProject.file("tools/community_hmac.secret") +def communityHmacFromFile = communityHmacFile.exists() ? communityHmacFile.text.trim() : "" +def communityHmacFromEnv = providers.environmentVariable("COMMUNITY_HMAC_SECRET") + .getOrElse("").trim() +def communityHmacSecret = communityHmacFromFile ?: communityHmacFromEnv +def communityApiBaseOverride = providers.gradleProperty("COMMUNITY_API_BASE") + .orElse(providers.environmentVariable("COMMUNITY_API_BASE")) + .getOrElse("").trim() +def communityApiBase = communityApiBaseOverride ?: "https://api.winnative.dev/api/v1/" +if (!communityHmacSecret) { + logger.warn("WinNative: COMMUNITY_HMAC_SECRET is empty - community config sharing will be " + + "DISABLED in this build. Set tools/community_hmac.secret or export " + + "COMMUNITY_HMAC_SECRET. Note: GitHub does not pass secrets to pull_request runs " + + "from a forked repository.") +} + android { namespace 'com.winlator.cmod' compileSdk 35 @@ -158,6 +174,8 @@ android { resConfigs "en", "da", "de", "es", "b+es+419", "fi", "fr", "hi", "it", "ja", "ko", "no", "pl", "pt", "pt-rBR", "ro", "ru", "sv", "th", "tr", "uk", "zh-rCN", "zh-rTW" buildConfigField("String", "COLD_CLIENT_VERSION", "\"${coldClientVersion}\"") + buildConfigField("String", "COMMUNITY_HMAC_SECRET", "\"${communityHmacSecret}\"") + buildConfigField("String", "COMMUNITY_API_BASE", "\"${communityApiBase}\"") buildConfigField "String", "RESHADE_CATALOG_URL", "\"https://raw.githubusercontent.com/nicholasx417/WinNative-Components/main/Reshade.json\"" } @@ -389,3 +407,5 @@ tasks.register("checkKotlinFormatOnly") { description = "Checks Kotlin formatting only." dependsOn("spotlessKotlinCheck") } + +apply from: rootProject.file("gradle/community-settings-check.gradle") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 309cd5ea7..96a1a9d86 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -103,3 +103,10 @@ -dontwarn androidx.window.sidecar.SidecarInterface -dontwarn androidx.window.sidecar.SidecarProvider -dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo + +# Community config sharing: keep kotlinx.serialization DTOs + generated +# serializers so JSON (de)serialization survives R8 in release builds. +-keepclassmembers class com.winlator.cmod.feature.community.net.** { + *** Companion; +} +-keep,includedescriptorclasses class com.winlator.cmod.feature.community.net.** { *; } diff --git a/app/src/main/feature/community/CommunitySettings.kt b/app/src/main/feature/community/CommunitySettings.kt new file mode 100644 index 000000000..bca69025b --- /dev/null +++ b/app/src/main/feature/community/CommunitySettings.kt @@ -0,0 +1,258 @@ +package com.winlator.cmod.feature.community + +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.reshade.ReshadeConfigWriter +import com.winlator.cmod.runtime.reshade.ReshadeLoadout +import org.json.JSONArray +import org.json.JSONObject + +object CommunitySettings { + + const val SCHEMA_VERSION = 2 + const val MIN_SCHEMA_VERSION = 1 + + enum class Source { CONTAINER, FALLBACK, SHORTCUT } + + class Entry( + val key: String, + val source: Source, + val maxLength: Int, + val steamOnly: Boolean, + val validate: (String) -> Boolean, + val containerDefault: (Container) -> String, + ) + + private val FORBIDDEN = listOf( + "..", "\$(", "\${", "`", "&&", "||", "|", "<", ">", "\\x", "/*", "*/", + ) + + private val BOOL01 = Regex("^[01]$") + private val INT_SMALL = Regex("^\\d{1,5}$") + private val CONTROLLER_COUNT = Regex("^[1-4]$") + private val RES = Regex("^\\d{2,5}x\\d{2,5}$") + private val IDENT = Regex("^[A-Za-z0-9._+\\- ]{0,64}$") + private val LOCALE = Regex("^[A-Za-z0-9._@\\-]{0,32}$") + private val CPULIST = Regex("^(\\d{1,3})(,\\d{1,3})*$") + private val WINCOMPONENTS = Regex("^([a-z0-9]+=-?\\d{1,2})(,[a-z0-9]+=-?\\d{1,2})*$") + private val THEME = Regex("^[A-Za-z0-9_,#\\- ]{0,48}$") + private val FONT = Regex("^[A-Za-z0-9._\\- ]{0,128}$") + private val KVBLOB = Regex("^[A-Za-z0-9_.,;=+\\-()\\[\\]:/ ]{0,2048}$") + private val ENV_TOKEN = Regex("^[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_.:,+/=\\-]{0,256}$") + private val EXECARGS = Regex("^[A-Za-z0-9 _.,:=+/\"'\\-]{0,512}$") + + private val AUDIO = setOf("alsa", "jack", "pulse", "pulseaudio") + private val PRESETS = setOf( + "COMPATIBILITY", "INTERMEDIATE", "PERFORMANCE", "STABILITY", "CUSTOM", + "MONOTHREAD", "PRIMUS", "", + ) + private val STARTUP = setOf("0", "1", "2") + private val TOUCH_MODE = setOf("0", "1", "2") + private val ZINK_MODE = setOf("", "unix", "windows") + private val RESHADE_MODE = setOf("", ReshadeLoadout.MODE_SOLO, ReshadeLoadout.MODE_STACK) + + private const val MAX_ENV_TOKENS = 64 + private const val MAX_RESHADE_PARAMS = 128 + + fun isSafeText(value: String): Boolean { + if (value.any { it.code < 0x20 || it.code == 0x7f }) return false + val low = value.lowercase() + return FORBIDDEN.none { low.contains(it) } + } + + private fun validEnvVars(value: String): Boolean { + if (value.isEmpty()) return true + val tokens = value.split(" ") + if (tokens.size > MAX_ENV_TOKENS) return false + return tokens.all { it.isEmpty() || ENV_TOKEN.matches(it) } + } + + private fun validReshadeLoadout(value: String): Boolean { + if (value.isEmpty()) return true + return try { + val arr = JSONArray(value) + if (arr.length() > ReshadeLoadout.MAX_EFFECTS) return false + var ok = true + for (i in 0 until arr.length()) { + val item = arr.optJSONObject(i) + val name = item?.optString("name", "") ?: "" + if (item == null || name.isBlank() || !IDENT.matches(name) || !item.has("enabled")) { + ok = false + } + } + ok + } catch (e: Exception) { + false + } + } + + private fun validReshadeParams(value: String): Boolean { + if (value.isEmpty()) return true + return try { + val root = JSONObject(value) + if (root.length() > ReshadeLoadout.MAX_EFFECTS) return false + var ok = true + for (name in root.keys()) { + val effect = root.optJSONObject(name) + if (!IDENT.matches(name) || effect == null || effect.length() > MAX_RESHADE_PARAMS) { + ok = false + continue + } + for (param in effect.keys()) { + if (!IDENT.matches(param) || effect.opt(param) !is Number) ok = false + } + } + ok + } catch (e: Exception) { + false + } + } + + private fun entry( + key: String, + source: Source, + maxLength: Int, + validate: (String) -> Boolean, + steamOnly: Boolean = false, + containerDefault: (Container) -> String = { it.getExtra(key, "") ?: "" }, + ) = Entry(key, source, maxLength, steamOnly, validate, containerDefault) + + private fun flag(value: Boolean) = if (value) "1" else "0" + + val ENTRIES: List = listOf( + entry("screenSize", Source.CONTAINER, 16, { RES.matches(it) }) { it.getScreenSize() ?: "" }, + entry("refreshRate", Source.FALLBACK, 5, { INT_SMALL.matches(it) }), + entry("fpsLimit", Source.SHORTCUT, 5, { INT_SMALL.matches(it) }), + entry("audioDriver", Source.CONTAINER, 16, { it in AUDIO }) { it.getAudioDriver() ?: "" }, + entry("midiSoundFont", Source.CONTAINER, 128, { FONT.matches(it) }) { + it.getMIDISoundFont() ?: "" + }, + entry("graphicsDriver", Source.CONTAINER, 32, { IDENT.matches(it) }) { + it.getGraphicsDriver() ?: "" + }, + entry("graphicsDriverConfig", Source.CONTAINER, 2048, { KVBLOB.matches(it) }) { + it.getGraphicsDriverConfig() ?: "" + }, + entry("zinkMode", Source.CONTAINER, 16, { it in ZINK_MODE }) { it.getZinkMode() ?: "" }, + entry("dxwrapper", Source.CONTAINER, 32, { IDENT.matches(it) }) { it.getDXWrapper() ?: "" }, + entry("dxwrapperConfig", Source.CONTAINER, 2048, { KVBLOB.matches(it) }) { + it.getDXWrapperConfig() ?: "" + }, + entry("swapRB", Source.CONTAINER, 1, { BOOL01.matches(it) }), + entry("sgsrEnabled", Source.SHORTCUT, 1, { BOOL01.matches(it) }), + entry("sgsrUpscaleMode", Source.SHORTCUT, 16, { IDENT.matches(it) }), + entry("sgsrSharpness", Source.SHORTCUT, 8, { IDENT.matches(it) }), + entry("wineVersion", Source.CONTAINER, 64, { IDENT.matches(it) }) { it.getWineVersion() ?: "" }, + entry("emulator", Source.CONTAINER, 32, { IDENT.matches(it) }) { it.getEmulator() ?: "" }, + entry("emulator64", Source.CONTAINER, 32, { IDENT.matches(it) }) { it.getEmulator64() ?: "" }, + entry("useUnixLibs", Source.CONTAINER, 1, { BOOL01.matches(it) }) { flag(it.isUseUnixLibs) }, + entry("lc_all", Source.CONTAINER, 32, { LOCALE.matches(it) }) { it.getLC_ALL() ?: "" }, + entry("desktopTheme", Source.CONTAINER, 48, { THEME.matches(it) }) { + it.getDesktopTheme() ?: "" + }, + entry("wincomponents", Source.CONTAINER, 512, { WINCOMPONENTS.matches(it) }) { + it.getWinComponents() ?: "" + }, + entry("envVars", Source.CONTAINER, 4096, { validEnvVars(it) }) { it.getEnvVars() ?: "" }, + entry("box64Version", Source.CONTAINER, 64, { IDENT.matches(it) }) { + it.getBox64Version() ?: "" + }, + entry("box64Preset", Source.CONTAINER, 24, { it in PRESETS }) { it.getBox64Preset() ?: "" }, + entry("fexcoreVersion", Source.CONTAINER, 64, { IDENT.matches(it) }) { + it.getFEXCoreVersion() ?: "" + }, + entry("fexcorePreset", Source.CONTAINER, 24, { it in PRESETS }) { + it.getFEXCorePreset() ?: "" + }, + entry("startupSelection", Source.CONTAINER, 1, { it in STARTUP }) { + it.getStartupSelection().toInt().toString() + }, + entry("execArgs", Source.CONTAINER, 512, { EXECARGS.matches(it) }) { it.getExecArgs() ?: "" }, + entry("fullscreenStretched", Source.CONTAINER, 1, { BOOL01.matches(it) }) { + flag(it.isFullscreenStretched) + }, + entry("cpuList", Source.CONTAINER, 96, { CPULIST.matches(it) }) { it.getCPUList(true) ?: "" }, + entry("cpuListWoW64", Source.CONTAINER, 96, { CPULIST.matches(it) }) { + it.getCPUListWoW64(true) ?: "" + }, + entry("inputType", Source.CONTAINER, 5, { INT_SMALL.matches(it) }) { + it.getInputType().toString() + }, + entry("exclusiveXInput", Source.CONTAINER, 1, { BOOL01.matches(it) }) { + flag(it.isExclusiveXInput) + }, + entry("numControllers", Source.SHORTCUT, 1, { CONTROLLER_COUNT.matches(it) }), + entry("disableXinput", Source.SHORTCUT, 1, { BOOL01.matches(it) }), + entry("simTouchScreen", Source.SHORTCUT, 1, { BOOL01.matches(it) }), + entry("screenTouchMode", Source.SHORTCUT, 1, { it in TOUCH_MODE }), + entry(ReshadeConfigWriter.EXTRA_LOADOUT, Source.CONTAINER, 512, { validReshadeLoadout(it) }), + entry(ReshadeConfigWriter.EXTRA_MODE, Source.CONTAINER, 8, { it in RESHADE_MODE }), + entry(ReshadeConfigWriter.EXTRA_PARAMS, Source.CONTAINER, 4096, { validReshadeParams(it) }), + entry(ReshadeConfigWriter.EXTRA_EFFECT, Source.CONTAINER, 64, { IDENT.matches(it) }), + entry("useColdClient", Source.CONTAINER, 1, { BOOL01.matches(it) }, steamOnly = true) { + flag(it.isUseColdClient) + }, + entry("unpackFiles", Source.CONTAINER, 1, { BOOL01.matches(it) }, steamOnly = true) { + flag(it.isUnpackFiles) + }, + entry("useSteamInput", Source.CONTAINER, 1, { BOOL01.matches(it) }, steamOnly = true), + entry("steamOfflineMode", Source.CONTAINER, 1, { BOOL01.matches(it) }, steamOnly = true) { + flag(it.isSteamOfflineMode) + }, + entry("runtimePatcher", Source.CONTAINER, 1, { BOOL01.matches(it) }, steamOnly = true) { + flag(it.isRuntimePatcher) + }, + ) + + val BY_KEY: Map = ENTRIES.associateBy { it.key } + + val KEYS: Set = BY_KEY.keys + + private val ADDED_IN_V2: Set = setOf( + "zinkMode", + "useUnixLibs", + "screenTouchMode", + ReshadeConfigWriter.EXTRA_LOADOUT, + ReshadeConfigWriter.EXTRA_MODE, + ReshadeConfigWriter.EXTRA_PARAMS, + ReshadeConfigWriter.EXTRA_EFFECT, + ) + + fun keysForSchema(version: Int): Set = + if (version >= 2) KEYS else KEYS - ADDED_IN_V2 + + val NON_PORTABLE: Set = setOf( + "custom_name", + "container_id", + "use_container_defaults", + "controlsProfile", + "gestureProfileId", + "launch_exe_path", + "custom_exe", + "custom_game_folder", + "cloud_force_download", + "launchRealSteam", + "steamType", + ) + + fun isSteam(shortcut: Shortcut): Boolean = + shortcut.getExtra("game_source", "").equals("steam", ignoreCase = true) + + fun effective(shortcut: Shortcut, entry: Entry): String { + val container: Container? = shortcut.container + val fromContainer = if (container != null) entry.containerDefault(container) else "" + return when (entry.source) { + Source.CONTAINER -> shortcut.getSettingExtra(entry.key, fromContainer) ?: "" + Source.FALLBACK -> shortcut.getExtra(entry.key, "").ifEmpty { fromContainer } + Source.SHORTCUT -> shortcut.getExtra(entry.key, "") + } + } + + fun accepts(entry: Entry, value: String): Boolean = + value.length <= entry.maxLength && isSafeText(value) && entry.validate(value) + + fun accepts(key: String, value: String): Boolean { + val entry = BY_KEY[key] ?: return false + return accepts(entry, value) + } +} diff --git a/app/src/main/feature/community/ComponentChecker.kt b/app/src/main/feature/community/ComponentChecker.kt new file mode 100644 index 000000000..3ac70e948 --- /dev/null +++ b/app/src/main/feature/community/ComponentChecker.kt @@ -0,0 +1,134 @@ +package com.winlator.cmod.feature.community + +import android.content.Context +import com.winlator.cmod.R +import com.winlator.cmod.feature.settings.DXVKConfigUtils +import com.winlator.cmod.feature.settings.GraphicsDriverConfigUtils +import com.winlator.cmod.runtime.content.AdrenotoolsManager +import com.winlator.cmod.runtime.content.ContentProfile +import com.winlator.cmod.runtime.content.ContentsManager +import com.winlator.cmod.runtime.reshade.ReshadeConfigWriter +import com.winlator.cmod.runtime.reshade.ReshadeLoadout +import com.winlator.cmod.runtime.reshade.ReshadeManager +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.wine.WineInfo +import org.json.JSONObject + +object ComponentChecker { + + data class Missing(val label: String) + + private val SKIP = setOf("", "none", "system", "builtin", "auto", "wined3d") + + fun findMissing( + context: Context, + contentsManager: ContentsManager, + settings: JSONObject, + ): List { + contentsManager.syncContents() + val missing = mutableListOf() + + val wineVer = settings.optString("wineVersion", "") + if (wineVer.lowercase() !in SKIP) { + val installed = installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_WINE) + + installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_PROTON) + val resolved = runCatching { + WineInfo.fromIdentifier(context, contentsManager, wineVer) + }.getOrNull() + if (resolved == null && !matches(wineVer, installed) && installed.isNotEmpty()) { + missing += Missing("Wine $wineVer") + } + } + + val dxwrapper = settings.optString("dxwrapper", "") + if (dxwrapper.lowercase() !in SKIP) { + val cfg = DXVKConfigUtils.parseConfig(settings.optString("dxwrapperConfig", "")) + checkVersion( + cfg.get("version"), "DXVK", + installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_DXVK), missing, + ) + checkVersion( + cfg.get("vkd3dVersion"), "VKD3D", + installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_VKD3D), missing, + ) + } + + checkVersion( + settings.optString("box64Version", ""), "Box64", + installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_BOX64) + + installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_WOWBOX64), + missing, + ) + + checkVersion( + settings.optString("fexcoreVersion", ""), "FEXCore", + installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_FEXCORE), missing, + ) + + val gdVersion = runCatching { + (GraphicsDriverConfigUtils.parseGraphicsDriverConfig( + settings.optString("graphicsDriverConfig", ""), + )["version"] ?: "").trim() + }.getOrDefault("") + if (gdVersion.isNotEmpty() && gdVersion.lowercase() !in SKIP && + !graphicsDriverAvailable(context, gdVersion) + ) { + missing += Missing("Graphics driver \"$gdVersion\"") + } + + missing += missingReshadeEffects(context, settings) + + return missing + } + + private fun missingReshadeEffects(context: Context, settings: JSONObject): List { + val loadout = settings.optString(ReshadeConfigWriter.EXTRA_LOADOUT, "") + val legacy = settings.optString(ReshadeConfigWriter.EXTRA_EFFECT, "") + val wanted = runCatching { ReshadeLoadout.parse(loadout, legacy) }.getOrNull().orEmpty() + if (wanted.isEmpty()) return emptyList() + val installed = runCatching { ReshadeManager.scanEffectNames(context) }.getOrNull().orEmpty() + return wanted + .filter { entry -> installed.none { it.equals(entry.name, ignoreCase = true) } } + .map { Missing("ReShade effect \"${it.name}\"") } + } + + private fun graphicsDriverAvailable(context: Context, version: String): Boolean { + runCatching { + val sys = context.resources.getStringArray(R.array.wrapper_graphics_driver_version_entries) + if (sys.any { it.equals(version, ignoreCase = true) }) { + return GPUInformation.isDriverSupported(version, context) + } + } + return runCatching { + AdrenotoolsManager(context).enumarateInstalledDrivers() + ?.any { it.equals(version, ignoreCase = true) } ?: false + }.getOrDefault(false) + } + + private fun checkVersion( + version: String?, + label: String, + installed: List, + out: MutableList, + ) { + val v = version?.trim() ?: "" + if (v.lowercase() in SKIP) return + if (!matches(v, installed)) out += Missing("$label $v") + } + + private fun installedNames(cm: ContentsManager, type: ContentProfile.ContentType): List { + val list = cm.getProfiles(type) ?: return emptyList() + return list.filter { it.isInstalled }.flatMap { + listOf(it.verName ?: "", ContentsManager.getEntryName(it)) + }.filter { it.isNotBlank() } + } + + private fun matches(version: String, installed: List): Boolean { + val v = version.lowercase() + if (v.isEmpty()) return true + return installed.any { name -> + val n = name.lowercase() + n == v || n.endsWith(v) || n.contains(v) || (n.length >= 4 && v.contains(n)) + } + } +} diff --git a/app/src/main/feature/community/ConfigApplier.kt b/app/src/main/feature/community/ConfigApplier.kt new file mode 100644 index 000000000..a6fcb7fb6 --- /dev/null +++ b/app/src/main/feature/community/ConfigApplier.kt @@ -0,0 +1,23 @@ +package com.winlator.cmod.feature.community + +import com.winlator.cmod.runtime.container.Shortcut +import org.json.JSONObject + +object ConfigApplier { + + fun apply(shortcut: Shortcut, settings: JSONObject) { + val steam = CommunitySettings.isSteam(shortcut) + for (entry in CommunitySettings.ENTRIES) { + if (entry.steamOnly && !steam) continue + if (!settings.has(entry.key)) { + shortcut.putExtra(entry.key, null) + continue + } + val value = settings.optString(entry.key, "") + if (!CommunitySettings.accepts(entry, value)) continue + shortcut.putExtra(entry.key, value) + } + shortcut.putExtra("use_container_defaults", "0") + shortcut.saveData() + } +} diff --git a/app/src/main/feature/community/ConfigSerializer.kt b/app/src/main/feature/community/ConfigSerializer.kt new file mode 100644 index 000000000..e22f829fd --- /dev/null +++ b/app/src/main/feature/community/ConfigSerializer.kt @@ -0,0 +1,50 @@ +package com.winlator.cmod.feature.community + +import com.winlator.cmod.runtime.container.Shortcut +import org.json.JSONObject + +object ConfigSerializer { + + fun serialize(shortcut: Shortcut): JSONObject { + val steam = CommunitySettings.isSteam(shortcut) + val out = JSONObject() + for (entry in CommunitySettings.ENTRIES) { + if (entry.steamOnly && !steam) continue + val value = CommunitySettings.effective(shortcut, entry) + if (value.isBlank()) continue + if (!CommunitySettings.accepts(entry, value)) continue + out.put(entry.key, value) + } + return out + } + + fun rejectedKeys(shortcut: Shortcut): List { + val steam = CommunitySettings.isSteam(shortcut) + return CommunitySettings.ENTRIES.filter { entry -> + if (entry.steamOnly && !steam) return@filter false + val value = CommunitySettings.effective(shortcut, entry) + value.isNotBlank() && !CommunitySettings.accepts(entry, value) + }.map { it.key } + } + + fun gameKey(shortcut: Shortcut): String { + return when (storeOf(shortcut)) { + "STEAM" -> "steam:" + shortcut.getExtra("app_id", "").ifBlank { slug(shortcut.name) } + "GOG" -> "gog:" + shortcut.getExtra("gog_id", "").ifBlank { slug(shortcut.name) } + "EPIC" -> "epic:" + slug(shortcut.name) + else -> "name:" + slug(shortcut.name) + }.take(128) + } + + fun storeOf(shortcut: Shortcut): String { + val raw = shortcut.getExtra("game_source", "").uppercase() + val allowed = setOf("STEAM", "EPIC", "GOG", "AMAZON", "UBISOFT", "EA", "BATTLENET") + return if (raw in allowed) raw else "CUSTOM" + } + + private fun slug(name: String): String { + val s = name.lowercase().map { if (it.isLetterOrDigit() || it == '-' || it == '.') it else '-' } + .joinToString("").trim('-') + return s.ifBlank { "game" }.take(100) + } +} diff --git a/app/src/main/feature/community/DeviceIdentity.kt b/app/src/main/feature/community/DeviceIdentity.kt new file mode 100644 index 000000000..829d6772e --- /dev/null +++ b/app/src/main/feature/community/DeviceIdentity.kt @@ -0,0 +1,119 @@ +package com.winlator.cmod.feature.community + +import android.os.Build + +object DeviceIdentity { + + @Volatile + private var cached: HardwareBlock? = null + + data class HardwareBlock( + val socModel: String, + val socManufacturer: String, + val boardPlatform: String, + val deviceCodename: String, + val modelNumber: String, + val modelRegion: String, + val brand: String, + val marketName: String, + ) + + private val PLACEHOLDERS = setOf( + "", "unknown", "null", "none", "n/a", "na", "0", "invalid", "undefined", + "not available", "default", "generic", + ) + + private val SOC_MODEL_PROPS = listOf( + "ro.soc.model", + "ro.vendor.qti.soc_model", + "ro.chipname", + "ro.mediatek.platform", + "ro.vendor.mediatek.platform", + "ro.hardware.chipname", + ) + + private val SOC_MANUFACTURER_PROPS = listOf( + "ro.soc.manufacturer", + "ro.vendor.qti.soc_manufacturer", + "ro.hardware.vendor", + ) + + private val BOARD_PROPS = listOf( + "ro.board.platform", + "ro.vendor.qti.soc_name", + "ro.hardware", + ) + + fun current(): HardwareBlock { + cached?.let { return it } + return build().also { cached = it } + } + + private fun build(): HardwareBlock { + val socCandidates = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + socCandidates += Build.SOC_MODEL.clean() + } + SOC_MODEL_PROPS.forEach { socCandidates += getprop(it) } + val soc = firstUsable(socCandidates) + + val mfrCandidates = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + mfrCandidates += Build.SOC_MANUFACTURER.clean() + } + SOC_MANUFACTURER_PROPS.forEach { mfrCandidates += getprop(it) } + val socMfr = firstUsable(mfrCandidates) + + val board = firstUsable(BOARD_PROPS.map { getprop(it) }) + + val market = firstUsable( + listOf( + getprop("ro.product.marketname"), + getprop("ro.product.odm.marketname"), + getprop("ro.config.marketing_name"), + getprop("ro.product.vendor.marketname"), + ) + ) + + return HardwareBlock( + socModel = soc.ifBlank { board }.take(48), + socManufacturer = socMfr.take(48), + boardPlatform = board.take(48), + deviceCodename = firstUsable( + listOf(Build.DEVICE.clean(), getprop("ro.product.device")) + ).take(48), + modelNumber = firstUsable( + listOf(Build.MODEL.clean(), getprop("ro.product.model")) + ).take(48), + modelRegion = getprop("ro.product.name").take(48), + brand = firstUsable( + listOf(Build.BRAND.clean(), getprop("ro.product.brand")) + ).take(48), + marketName = market.take(64), + ) + } + + fun chipsetKey(): String { + val hw = current() + return hw.socModel.ifBlank { hw.boardPlatform } + } + + private fun firstUsable(values: List): String = + values.firstOrNull { isUsable(it) } ?: "" + + private fun isUsable(value: String): Boolean { + val v = value.trim() + if (v.isEmpty() || v.lowercase() in PLACEHOLDERS) return false + return v.any { it.isLetterOrDigit() } + } + + private fun getprop(key: String): String = runCatching { + val p = Runtime.getRuntime().exec(arrayOf("getprop", key)) + val out = p.inputStream.bufferedReader().use { it.readLine() } ?: "" + p.waitFor() + out.clean() + }.getOrDefault("") + + private fun String?.clean(): String = + (this ?: "").trim().filter { it.code in 32..126 } +} diff --git a/app/src/main/feature/community/UploaderIdentity.kt b/app/src/main/feature/community/UploaderIdentity.kt new file mode 100644 index 000000000..d120ff4ac --- /dev/null +++ b/app/src/main/feature/community/UploaderIdentity.kt @@ -0,0 +1,109 @@ +package com.winlator.cmod.feature.community + +import android.app.Activity +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.google.android.gms.games.PlayGames +import com.winlator.cmod.feature.sync.google.PlayGamesBootstrap +import java.security.MessageDigest +import java.util.UUID + +object UploaderIdentity { + + @Volatile + private var cachedGoogleId: String? = null + + @Volatile + private var cachedDeviceUuid: String? = null + + @Volatile + private var cachedDisplayName: String? = null + + fun handle(context: Context): String { + val base = cachedGoogleId ?: deviceUuid(context) + return sha256Hex("wn1:$base") + } + + fun isGoogleBacked(): Boolean = cachedGoogleId != null + + fun displayName(): String = cachedDisplayName ?: "" + + fun resolveGoogle(activity: Activity, onDone: (Boolean) -> Unit = {}) { + runCatching { + PlayGamesBootstrap.ensureInitialized(activity) + PlayGames.getPlayersClient(activity).currentPlayer + .addOnSuccessListener { player -> + val id = player?.playerId + if (!id.isNullOrBlank()) { + cachedGoogleId = id + cachedDisplayName = player?.displayName + onDone(true) + } else onDone(false) + } + .addOnFailureListener { onDone(false) } + }.onFailure { onDone(false) } + } + + fun signInAndResolve( + activity: Activity, + onInteractiveSignIn: () -> Unit = {}, + onDone: (Boolean) -> Unit, + ) { + if (isGoogleBacked()) { + onDone(true) + return + } + runCatching { + PlayGamesBootstrap.ensureInitialized(activity) + val client = PlayGames.getGamesSignInClient(activity) + client.isAuthenticated.addOnCompleteListener { t -> + if (t.isSuccessful && t.result?.isAuthenticated == true) { + resolveGoogle(activity, onDone) + } else { + onInteractiveSignIn() + client.signIn().addOnCompleteListener { s -> + if (s.isSuccessful && s.result?.isAuthenticated == true) { + resolveGoogle(activity, onDone) + } else onDone(false) + } + } + } + }.onFailure { onDone(false) } + } + + private fun deviceUuid(context: Context): String { + cachedDeviceUuid?.let { return it } + synchronized(this) { + cachedDeviceUuid?.let { return it } + val prefs = securePrefs(context) + var uuid = prefs.getString("uuid", null) + if (uuid.isNullOrBlank()) { + uuid = UUID.randomUUID().toString() + prefs.edit().putString("uuid", uuid).apply() + } + cachedDeviceUuid = uuid + return uuid + } + } + + private fun securePrefs(context: Context) = try { + val masterKey = MasterKey.Builder(context.applicationContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context.applicationContext, + "community_identity_enc", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } catch (e: Exception) { + context.applicationContext.getSharedPreferences("community_identity", Context.MODE_PRIVATE) + } + + private fun sha256Hex(s: String): String { + val d = MessageDigest.getInstance("SHA-256").digest(s.toByteArray()) + return d.joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/main/feature/community/net/CommunityApiClient.kt b/app/src/main/feature/community/net/CommunityApiClient.kt new file mode 100644 index 000000000..fd9d9d13c --- /dev/null +++ b/app/src/main/feature/community/net/CommunityApiClient.kt @@ -0,0 +1,196 @@ +package com.winlator.cmod.feature.community.net + +import android.content.Context +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.feature.community.CommunitySettings +import com.winlator.cmod.feature.community.DeviceIdentity +import com.winlator.cmod.feature.community.UploaderIdentity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +class CommunityApiException(val code: Int, val detail: String) : IOException(detail) + +class CommunityApiClient(private val context: Context) { + + companion object { + private const val MAX_RESPONSE_BYTES = 1L shl 20 + + private val JSON_MEDIA = "application/json".toMediaType() + + private val client: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(8, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .callTimeout(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + } + + private val json = Json { ignoreUnknownKeys = true } + + private val base: HttpUrl? by lazy { + BuildConfig.COMMUNITY_API_BASE.toHttpUrlOrNull() + ?.takeIf { it.isHttps || BuildConfig.DEBUG } + } + + fun isConfigured(): Boolean = + base != null && RequestSigner.isConfigured() + } + + private fun requireBase(): HttpUrl = + base ?: throw IOException("Community sharing is not available in this build") + + suspend fun listConfigs( + gameKey: String, + filter: CommunityFilter, + hw: DeviceIdentity.HardwareBlock, + ): ListResponse = withContext(Dispatchers.IO) { + val url = requireBase().newBuilder() + .addPathSegment("configs") + .addQueryParameter("gameKey", gameKey) + .addQueryParameter("filter", filter.wire) + .addQueryParameter("soc", hw.socModel) + .addQueryParameter("board", hw.boardPlatform) + .addQueryParameter("brand", hw.brand) + .addQueryParameter("model", hw.modelNumber) + .addQueryParameter("codename", hw.deviceCodename) + .build() + json.decodeFromString(ListResponse.serializer(), exec("GET", url, null)) + } + + suspend fun fetchSettings(id: String): JSONObject = withContext(Dispatchers.IO) { + val url = requireBase().newBuilder() + .addPathSegment("configs").addPathSegment(id).build() + val obj = JSONObject(exec("GET", url, null)) + val settings = obj.optJSONObject("settings") ?: JSONObject() + val safe = JSONObject() + val keys = settings.keys() + while (keys.hasNext()) { + val key = keys.next() + val value = settings.optString(key, "") + if (CommunitySettings.accepts(key, value)) safe.put(key, value) + } + safe + } + + suspend fun upload( + gameKey: String, + store: String, + settings: JSONObject, + hw: DeviceIdentity.HardwareBlock, + ): UploadResult = withContext(Dispatchers.IO) { + val url = requireBase().newBuilder().addPathSegment("configs").build() + try { + send(url, gameKey, store, settings, hw, CommunitySettings.SCHEMA_VERSION) + } catch (e: CommunityApiException) { + if (!isLegacySchemaRejection(e)) throw e + val version = CommunitySettings.MIN_SCHEMA_VERSION + val allowed = CommunitySettings.keysForSchema(version) + val reduced = JSONObject() + val dropped = mutableListOf() + val keys = settings.keys() + while (keys.hasNext()) { + val key = keys.next() + if (key in allowed) reduced.put(key, settings.optString(key, "")) + else dropped += key + } + val result = send(url, gameKey, store, reduced, hw, version) + result.copy(droppedKeys = (result.droppedKeys + dropped).distinct()) + } + } + + private fun isLegacySchemaRejection(e: CommunityApiException): Boolean = + e.code in 400..499 && + (e.detail.contains("schemaVersion") || e.detail.contains("unknown setting key")) + + private fun send( + url: HttpUrl, + gameKey: String, + store: String, + settings: JSONObject, + hw: DeviceIdentity.HardwareBlock, + schemaVersion: Int, + ): UploadResult { + val payload = JSONObject() + .put("schemaVersion", schemaVersion) + .put("gameKey", gameKey) + .put("store", store) + .put("uploaderName", UploaderIdentity.displayName()) + .put("settings", settings) + .put( + "hardware", + JSONObject() + .put("socModel", hw.socModel) + .put("socManufacturer", hw.socManufacturer) + .put("boardPlatform", hw.boardPlatform) + .put("deviceCodename", hw.deviceCodename) + .put("modelNumber", hw.modelNumber) + .put("modelRegion", hw.modelRegion) + .put("brand", hw.brand) + .put("marketName", hw.marketName), + ) + return json.decodeFromString( + UploadResult.serializer(), + exec("POST", url, payload.toString().toByteArray()), + ) + } + + suspend fun deleteConfig(id: String): Boolean = withContext(Dispatchers.IO) { + val url = requireBase().newBuilder() + .addPathSegment("configs").addPathSegment(id).build() + exec("DELETE", url, ByteArray(0)) + true + } + + suspend fun vote(id: String, up: Boolean): VoteResult = withContext(Dispatchers.IO) { + val url = requireBase().newBuilder().addPathSegment("configs").addPathSegment(id) + .addPathSegment("vote").build() + val body = JSONObject().put("value", if (up) 1 else -1).toString().toByteArray() + json.decodeFromString(VoteResult.serializer(), exec("POST", url, body)) + } + + suspend fun report(id: String, reason: String): Boolean = withContext(Dispatchers.IO) { + val url = requireBase().newBuilder().addPathSegment("configs").addPathSegment(id) + .addPathSegment("report").build() + val body = JSONObject().put("reason", reason).toString().toByteArray() + exec("POST", url, body) + true + } + + private fun exec(method: String, url: HttpUrl, body: ByteArray?): String { + val bodyBytes = body ?: ByteArray(0) + val handle = UploaderIdentity.handle(context) + val googleBacked = UploaderIdentity.isGoogleBacked() + val headers = RequestSigner.headers( + method, url.encodedPath, bodyBytes, handle, googleBacked, + ) + val builder = Request.Builder().url(url) + when (method) { + "GET" -> builder.get() + "DELETE" -> builder.delete(bodyBytes.toRequestBody(JSON_MEDIA)) + else -> builder.method(method, bodyBytes.toRequestBody(JSON_MEDIA)) + } + headers.forEach { (k, v) -> builder.header(k, v) } + client.newCall(builder.build()).execute().use { resp -> + val text = resp.peekBody(MAX_RESPONSE_BYTES).string() + if (!resp.isSuccessful) { + val detail = runCatching { JSONObject(text).optString("detail") }.getOrDefault("") + throw CommunityApiException( + resp.code, + if (detail.isNotBlank()) detail else "HTTP ${resp.code}", + ) + } + return text + } + } +} diff --git a/app/src/main/feature/community/net/CommunityModels.kt b/app/src/main/feature/community/net/CommunityModels.kt new file mode 100644 index 000000000..60d4b9161 --- /dev/null +++ b/app/src/main/feature/community/net/CommunityModels.kt @@ -0,0 +1,47 @@ +package com.winlator.cmod.feature.community.net + +import kotlinx.serialization.Serializable + +@Serializable +data class ConfigSummary( + val id: String, + val resolution: String = "", + val store: String = "", + val uploaderHandle: String = "", + val dxwrapper: String = "", + val wineVersion: String = "", + val deviceModel: String = "", + val marketName: String = "", + val up: Int = 0, + val down: Int = 0, + val myVote: Int = 0, + val ownedByMe: Boolean = false, + val schemaVersion: Int = 1, + val createdAt: Long = 0, +) + +@Serializable +data class ListResponse( + val configs: List = emptyList(), + val filter: String = "chipset", + val deviceDisplay: String = "", + val chipsetDisplay: String = "", +) + +@Serializable +data class UploadResult( + val id: String = "", + val exportName: String = "", + val droppedKeys: List = emptyList(), +) + +@Serializable +data class VoteResult( + val up: Int = 0, + val down: Int = 0, + val myVote: Int = 0, +) + +enum class CommunityFilter(val wire: String) { + CHIPSET("chipset"), DEVICE("device"), ALL("all") +} diff --git a/app/src/main/feature/community/net/RequestSigner.kt b/app/src/main/feature/community/net/RequestSigner.kt new file mode 100644 index 000000000..6fe5f0a11 --- /dev/null +++ b/app/src/main/feature/community/net/RequestSigner.kt @@ -0,0 +1,47 @@ +package com.winlator.cmod.feature.community.net + +import com.winlator.cmod.BuildConfig +import java.io.IOException +import java.security.MessageDigest +import java.util.UUID +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +object RequestSigner { + + private val secret: ByteArray = BuildConfig.COMMUNITY_HMAC_SECRET.toByteArray() + + fun isConfigured(): Boolean = secret.isNotEmpty() + + fun headers( + method: String, + path: String, + body: ByteArray, + uploaderHandle: String, + googleBacked: Boolean, + ): Map { + if (!isConfigured()) { + throw IOException("Community sharing is not available in this build") + } + val ts = (System.currentTimeMillis() / 1000L).toString() + val nonce = UUID.randomUUID().toString() + val bodyHash = sha256Hex(body) + val msg = "$method\n$path\n$ts\n$nonce\n$bodyHash" + return mapOf( + "X-Wn-Timestamp" to ts, + "X-Wn-Nonce" to nonce, + "X-Wn-Signature" to hmacHex(msg), + "X-Wn-Uploader" to uploaderHandle, + "X-Wn-Auth" to (if (googleBacked) "google" else "device"), + ) + } + + private fun hmacHex(msg: String): String { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(secret, "HmacSHA256")) + return mac.doFinal(msg.toByteArray()).joinToString("") { "%02x".format(it) } + } + + private fun sha256Hex(b: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(b).joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/feature/community/ui/CommunityConfigDownloadDialog.kt b/app/src/main/feature/community/ui/CommunityConfigDownloadDialog.kt new file mode 100644 index 000000000..00a243d8e --- /dev/null +++ b/app/src/main/feature/community/ui/CommunityConfigDownloadDialog.kt @@ -0,0 +1,499 @@ +package com.winlator.cmod.feature.community.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.winlator.cmod.feature.community.ComponentChecker +import com.winlator.cmod.feature.community.DeviceIdentity +import com.winlator.cmod.feature.community.net.CommunityApiClient +import com.winlator.cmod.feature.community.net.CommunityFilter +import com.winlator.cmod.feature.community.net.ConfigSummary +import com.winlator.cmod.shared.theme.GameSettingsStyle +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import kotlinx.coroutines.launch +import org.json.JSONObject + +private val WsBg = Color(0xFF12121B) +private val Card = GameSettingsStyle.CardSurface +private val CardBorder = GameSettingsStyle.CardBorder +private val InputBg = GameSettingsStyle.InputSurface +private val Accent = GameSettingsStyle.AccentBlue +private val TextPrimary = GameSettingsStyle.TextPrimary +private val TextSecondary = GameSettingsStyle.TextSecondary +private val TextDim = GameSettingsStyle.TextDim +private val NavHighlight = GameSettingsStyle.NavHighlight +private val Up = Color(0xFF4CD07D) +private val Down = GameSettingsStyle.DangerRed +private val Scrim = Color(0xFF000000) + +@Composable +internal fun CommunityConfigDownloadScreen( + gameTitle: String, + gameKey: String, + hw: DeviceIdentity.HardwareBlock, + api: CommunityApiClient, + registry: PaneNavRegistry, + applyConfig: suspend (JSONObject) -> List, + onAppliedDismiss: () -> Unit, + onClose: () -> Unit, + toast: (String) -> Unit, + voteGate: (() -> Unit) -> Unit = { it() }, + openPreview: (JSONObject) -> Unit = {}, + registerBackHandler: (() -> Boolean) -> Unit = {}, +) { + val scope = rememberCoroutineScope() + var filter by remember { mutableStateOf(CommunityFilter.CHIPSET) } + var configs by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var deviceDisplay by remember { mutableStateOf("") } + var chipsetDisplay by remember { mutableStateOf("") } + var missing by remember { mutableStateOf?>(null) } + var reportTarget by remember { mutableStateOf(null) } + var deleteTarget by remember { mutableStateOf(null) } + + val overlayRegistry = remember { PaneNavRegistry() } + val overlayOpen = missing != null || reportTarget != null || deleteTarget != null + + SideEffect { + registry.overlay = if (overlayOpen) overlayRegistry else null + registerBackHandler { + when { + missing != null -> { missing = null; true } + reportTarget != null -> { reportTarget = null; true } + deleteTarget != null -> { deleteTarget = null; true } + else -> false + } + } + } + DisposableEffect(Unit) { onDispose { registry.overlay = null } } + LaunchedEffect(overlayOpen) { if (overlayOpen) overlayRegistry.reset() } + + suspend fun reload() { + loading = true + error = null + runCatching { api.listConfigs(gameKey, filter, hw) } + .onSuccess { + configs = it.configs + deviceDisplay = it.deviceDisplay + chipsetDisplay = it.chipsetDisplay + loading = false + } + .onFailure { error = it.message ?: "Failed to load"; loading = false } + } + LaunchedEffect(filter) { reload() } + + fun update(id: String, transform: (ConfigSummary) -> ConfigSummary) { + configs = configs.map { if (it.id == id) transform(it) else it } + } + fun doApply(settings: JSONObject) { + scope.launch { + runCatching { applyConfig(settings) } + .onSuccess { miss -> if (miss.isEmpty()) onAppliedDismiss() else missing = miss } + .onFailure { toast(it.message ?: "Failed to apply") } + } + } + fun fetchThen(id: String, action: (JSONObject) -> Unit) { + scope.launch { + runCatching { api.fetchSettings(id) } + .onSuccess { action(it) } + .onFailure { toast(it.message ?: "This config is no longer available"); reload() } + } + } + + Box( + Modifier.fillMaxWidth().fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + CompositionLocalProvider(LocalPaneNav provides registry) { + Column( + Modifier.fillMaxWidth().fillMaxHeight().clip(RoundedCornerShape(16.dp)) + .background(WsBg).border(1.dp, CardBorder, RoundedCornerShape(16.dp)) + .padding(16.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column { + Text( + "Community Configs", color = TextPrimary, fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + Text(gameTitle, color = TextSecondary, fontSize = 12.sp, maxLines = 1) + } + Spacer(Modifier.width(20.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Chip("Chipset", filter == CommunityFilter.CHIPSET, isEntry = true) { + filter = CommunityFilter.CHIPSET + } + Chip("Device", filter == CommunityFilter.DEVICE) { + filter = CommunityFilter.DEVICE + } + Chip("All", filter == CommunityFilter.ALL) { filter = CommunityFilter.ALL } + } + Spacer(Modifier.weight(1f)) + Pill("Close", TextSecondary, onClick = onClose) + } + val sub = when (filter) { + CommunityFilter.CHIPSET -> + "Chipset: ${chipsetDisplay.ifBlank { hw.socModel.ifBlank { hw.boardPlatform } }}" + CommunityFilter.DEVICE -> "Device: ${deviceDisplay.ifBlank { hw.modelNumber }}" + CommunityFilter.ALL -> "All devices" + } + Text(sub, color = TextDim, fontSize = 10.sp, modifier = Modifier.padding(top = 6.dp)) + Spacer(Modifier.height(10.dp)) + + Box(Modifier.fillMaxWidth().weight(1f)) { + when { + loading -> Box(Modifier.fillMaxWidth().padding(24.dp), Alignment.Center) { + CircularProgressIndicator(color = Accent, strokeWidth = 2.dp) + } + error != null -> CenterText("⚠ $error", Down) + configs.isEmpty() -> + CenterText("No community configs for this game yet.", TextDim) + else -> LazyVerticalGrid( + columns = GridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(configs, key = { it.id }) { cfg -> + ConfigRow( + cfg = cfg, + onApply = { fetchThen(cfg.id) { doApply(it) } }, + onPreview = { fetchThen(cfg.id) { openPreview(it) } }, + onVote = { up -> + voteGate { + scope.launch { + runCatching { api.vote(cfg.id, up) } + .onSuccess { v -> + update(cfg.id) { + it.copy(up = v.up, down = v.down, myVote = v.myVote) + } + } + .onFailure { toast(it.message ?: "Vote failed") } + } + } + }, + onReport = { reportTarget = cfg.id }, + onDelete = { deleteTarget = cfg.id }, + ) + } + } + } + } + } + } + + CompositionLocalProvider(LocalPaneNav provides overlayRegistry) { + missing?.let { MissingComponentDialog(it) { missing = null } } + + deleteTarget?.let { id -> + ConfirmDialog( + title = "Delete this config?", + message = "It will be removed from the community list for everyone. " + + "This cannot be undone.", + confirmLabel = "Delete", + confirmTint = Down, + onConfirm = { + deleteTarget = null + scope.launch { + runCatching { api.deleteConfig(id) } + .onSuccess { + configs = configs.filterNot { c -> c.id == id } + toast("Deleted") + } + .onFailure { toast(it.message ?: "Delete failed") } + } + }, + onCancel = { deleteTarget = null }, + ) + } + + reportTarget?.let { id -> + ReportDialog( + onSubmit = { reason -> + reportTarget = null + scope.launch { + runCatching { api.report(id, reason) } + .onSuccess { toast("Reported — thank you") } + .onFailure { toast(it.message ?: "Report failed") } + } + }, + onCancel = { reportTarget = null }, + ) + } + } + } +} + +@Composable +private fun ConfigRow( + cfg: ConfigSummary, + onApply: () -> Unit, + onPreview: () -> Unit, + onVote: (Boolean) -> Unit, + onReport: () -> Unit, + onDelete: () -> Unit, +) { + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(Card) + .border(1.dp, CardBorder, RoundedCornerShape(10.dp)).padding(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column( + Modifier.weight(1f) + .paneNavItem( + cornerRadius = 8.dp, + onActivate = onApply, + onSecondary = onPreview, + highlightColor = NavHighlight, + tapToSelect = true, + ), + ) { + Text( + "${cfg.resolution.ifBlank { "—" }} · ${cfg.store.ifBlank { "—" }} · ${cfg.uploaderHandle}", + color = TextPrimary, fontSize = 12.sp, fontWeight = FontWeight.Medium, + ) + val meta = listOfNotNull( + cfg.dxwrapper.takeIf { it.isNotBlank() }, + cfg.wineVersion.takeIf { it.isNotBlank() }, + ).joinToString(" · ") + if (meta.isNotBlank()) { + Text( + meta, color = TextDim, fontSize = 10.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + VoteBtn("▲", cfg.up, cfg.myVote == 1, Up) { onVote(true) } + Spacer(Modifier.width(6.dp)) + VoteBtn("▼", cfg.down, cfg.myVote == -1, Down) { onVote(false) } + } + Spacer(Modifier.height(8.dp)) + @OptIn(ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Pill("Apply", Accent, onClick = onApply) + Pill("Report", TextSecondary, onClick = onReport) + Pill("Preview", Accent, onClick = onPreview) + if (cfg.ownedByMe) Pill("Delete", Down, onClick = onDelete) + } + } +} + +@Composable +private fun VoteBtn(glyph: String, count: Int, active: Boolean, color: Color, onClick: () -> Unit) { + Row( + Modifier.clip(RoundedCornerShape(8.dp)) + .background(if (active) color.copy(alpha = 0.16f) else InputBg) + .border( + 1.dp, if (active) color.copy(alpha = 0.5f) else CardBorder, + RoundedCornerShape(8.dp), + ) + .paneNavItem( + cornerRadius = 8.dp, + onActivate = onClick, + highlightColor = NavHighlight, + tapToSelect = true, + ) + .clickable { onClick() }.padding(horizontal = 9.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(glyph, color = if (active) color else TextSecondary, fontSize = 11.sp) + Spacer(Modifier.width(5.dp)) + Text( + "$count", color = if (active) color else TextSecondary, fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun Chip(label: String, selected: Boolean, isEntry: Boolean = false, onClick: () -> Unit) { + Box( + Modifier.clip(RoundedCornerShape(8.dp)) + .background(if (selected) Accent.copy(alpha = 0.12f) else InputBg) + .border( + 1.dp, if (selected) Accent.copy(alpha = 0.5f) else CardBorder, + RoundedCornerShape(8.dp), + ) + .paneNavItem( + cornerRadius = 8.dp, + onActivate = onClick, + highlightColor = NavHighlight, + tapToSelect = true, + isEntry = isEntry, + ) + .clickable { onClick() }.padding(horizontal = 14.dp, vertical = 7.dp), + ) { + Text( + label, color = if (selected) Accent else TextSecondary, fontSize = 11.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ) + } +} + +@Composable +private fun Pill( + label: String, + tint: Color, + enabled: Boolean = true, + isEntry: Boolean = false, + onClick: () -> Unit, +) { + Box( + Modifier.clip(RoundedCornerShape(8.dp)).background(tint.copy(alpha = 0.08f)) + .border(1.dp, tint.copy(alpha = 0.25f), RoundedCornerShape(8.dp)) + .paneNavItem( + cornerRadius = 8.dp, + onActivate = { if (enabled) onClick() }, + highlightColor = NavHighlight, + tapToSelect = true, + isEntry = isEntry, + ) + .clickable(enabled = enabled) { onClick() }.padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + label, color = if (enabled) tint else TextDim, fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun CenterText(text: String, color: Color) { + Box(Modifier.fillMaxWidth().padding(24.dp), Alignment.Center) { + Text(text, color = color, fontSize = 12.sp) + } +} + +@Composable +private fun ConfirmDialog( + title: String, + message: String, + confirmLabel: String, + confirmTint: Color, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + Box( + Modifier.fillMaxWidth().fillMaxHeight().background(Scrim.copy(alpha = 0.6f)) + .clickable { onCancel() }, + contentAlignment = Alignment.Center, + ) { + Column( + Modifier.fillMaxWidth(0.78f).clip(RoundedCornerShape(14.dp)).background(Card) + .border(1.dp, CardBorder, RoundedCornerShape(14.dp)).padding(16.dp) + .clickable(enabled = false) {}, + ) { + Text(title, color = TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Text( + message, color = TextDim, fontSize = 11.sp, + modifier = Modifier.padding(top = 6.dp, bottom = 14.dp), + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Spacer(Modifier.weight(1f)) + Pill("Cancel", TextSecondary, isEntry = true, onClick = onCancel) + Pill(confirmLabel, confirmTint, onClick = onConfirm) + } + } + } +} + +@Composable +private fun ReportDialog(onSubmit: (String) -> Unit, onCancel: () -> Unit) { + var reason by remember { mutableStateOf("") } + val focus = remember { FocusRequester() } + Box( + Modifier.fillMaxWidth().fillMaxHeight().background(Scrim.copy(alpha = 0.6f)) + .clickable { onCancel() }, + contentAlignment = Alignment.Center, + ) { + Column( + Modifier.fillMaxWidth(0.85f).clip(RoundedCornerShape(14.dp)).background(Card) + .border(1.dp, CardBorder, RoundedCornerShape(14.dp)).padding(16.dp) + .clickable(enabled = false) {}, + ) { + Text( + "Report config", color = TextPrimary, fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Why are you reporting this? (letters, digits and basic punctuation)", + color = TextDim, fontSize = 11.sp, + modifier = Modifier.padding(top = 4.dp, bottom = 10.dp), + ) + Box( + Modifier.fillMaxWidth().height(80.dp).clip(RoundedCornerShape(8.dp)) + .background(InputBg).border(1.dp, CardBorder, RoundedCornerShape(8.dp)) + .paneNavItem( + cornerRadius = 8.dp, + onActivate = { focus.requestFocus() }, + highlightColor = NavHighlight, + tapToSelect = true, + isEntry = true, + ) + .padding(10.dp), + ) { + BasicTextField( + value = reason, + onValueChange = { if (it.length <= 500) reason = it }, + textStyle = TextStyle(color = TextPrimary, fontSize = 12.sp), + cursorBrush = SolidColor(Accent), + modifier = Modifier.fillMaxWidth().focusRequester(focus), + ) + if (reason.isBlank()) Text("Reason…", color = TextDim, fontSize = 12.sp) + } + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Spacer(Modifier.weight(1f)) + Pill("Cancel", TextSecondary, onClick = onCancel) + Pill("Submit", Accent, enabled = reason.trim().length >= 3) { + if (reason.trim().length >= 3) onSubmit(reason.trim()) + } + } + } + } +} diff --git a/app/src/main/feature/community/ui/CommunityController.kt b/app/src/main/feature/community/ui/CommunityController.kt new file mode 100644 index 000000000..fbb39654e --- /dev/null +++ b/app/src/main/feature/community/ui/CommunityController.kt @@ -0,0 +1,253 @@ +package com.winlator.cmod.feature.community.ui + +import android.app.Activity +import android.app.Dialog +import android.os.Build +import android.util.Log +import android.view.Gravity +import android.view.ViewGroup +import android.view.Window +import android.view.WindowInsets +import android.view.WindowManager +import android.widget.Toast +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.LifecycleOwner +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import com.winlator.cmod.R +import com.winlator.cmod.feature.community.ComponentChecker +import com.winlator.cmod.feature.community.ConfigApplier +import com.winlator.cmod.feature.community.ConfigSerializer +import com.winlator.cmod.feature.community.DeviceIdentity +import com.winlator.cmod.feature.community.UploaderIdentity +import com.winlator.cmod.feature.community.net.CommunityApiClient +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.content.ContentsManager +import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.bindPaneNav +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject + +class CommunityController( + private val activity: Activity, + private val shortcut: Shortcut, + private val contentsManager: ContentsManager, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val api = CommunityApiClient(activity) + private var downloadDialog: Dialog? = null + private var restoreDownloadNav: (() -> Unit)? = null + private var overlayBack: (() -> Boolean)? = null + + var onConfigApplied: () -> Unit = {} + + private fun guard(what: String, block: () -> Unit) { + try { + block() + } catch (t: Throwable) { + Log.e(TAG, "community $what failed", t) + toast("Community $what failed: ${t.javaClass.simpleName}: ${t.message ?: "no message"}") + } + } + + init { + UploaderIdentity.resolveGoogle(activity) + } + + fun upload() = guard("upload") { uploadInternal() } + + private fun uploadInternal() { + if (!CommunityApiClient.isConfigured()) { + toast("Community sharing is not available in this build") + return + } + ensureGoogle { + toast("Uploading…") + scope.launch { + runCatching { + val settings = ConfigSerializer.serialize(shortcut) + val skipped = ConfigSerializer.rejectedKeys(shortcut) + val gameKey = ConfigSerializer.gameKey(shortcut) + val store = ConfigSerializer.storeOf(shortcut) + val hw = DeviceIdentity.current() + api.upload(gameKey, store, settings, hw) to skipped + }.onSuccess { (result, skipped) -> + val dropped = (result.droppedKeys + skipped).distinct() + if (dropped.isEmpty()) { + toast("Upload successful") + } else { + toast("Upload successful — skipped: ${dropped.joinToString(", ")}") + } + }.onFailure { + toast("Upload failed: ${it.message ?: "unknown error"}") + } + } + } + } + + fun ensureGoogle(action: () -> Unit) { + if (UploaderIdentity.isGoogleBacked()) { + action() + return + } + UploaderIdentity.signInAndResolve( + activity, + onInteractiveSignIn = { toast("Sign in with a Google account…") }, + ) { ok -> + if (ok) action() else toast("Google sign-in is required to upload or vote") + } + } + + fun dispose() { + restoreDownloadNav?.invoke() + restoreDownloadNav = null + overlayBack = null + downloadDialog = null + scope.cancel() + } + + private fun toast(msg: String) { + activity.runOnUiThread { Toast.makeText(activity, msg, Toast.LENGTH_LONG).show() } + } + + fun openDownload() = guard("download") { openDownloadInternal() } + + private fun openDownloadInternal() { + val lifecycleOwner = activity as? LifecycleOwner + val savedStateOwner = activity as? SavedStateRegistryOwner + if (lifecycleOwner == null || savedStateOwner == null) { + toast("Community sharing is unavailable here") + return + } + if (!CommunityApiClient.isConfigured()) { + toast("Community sharing is not available in this build") + return + } + val gameKey = ConfigSerializer.gameKey(shortcut) + val hw = DeviceIdentity.current() + val navRegistry = PaneNavRegistry() + val dialog = Dialog(activity, R.style.ContentDialog).apply { + requestWindowFeature(Window.FEATURE_NO_TITLE) + setCancelable(true) + setCanceledOnTouchOutside(false) + setOwnerActivity(activity) + window?.apply { + setBackgroundDrawableResource(android.R.color.transparent) + setGravity(Gravity.CENTER) + setDimAmount(0.5f) + addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) + } + setOnDismissListener { + restoreDownloadNav?.invoke() + restoreDownloadNav = null + overlayBack = null + downloadDialog = null + } + } + downloadDialog = dialog + val composeView = ComposeView(activity).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setViewTreeLifecycleOwner(lifecycleOwner) + setViewTreeSavedStateRegistryOwner(savedStateOwner) + setContent { + WinNativeTheme { + CommunityConfigDownloadScreen( + gameTitle = shortcut.name, + gameKey = gameKey, + hw = hw, + api = api, + registry = navRegistry, + applyConfig = { settings -> applyConfig(settings) }, + onAppliedDismiss = { + onConfigApplied() + toast("Config applied") + dialog.dismiss() + }, + onClose = { dialog.dismiss() }, + toast = { msg -> toast(msg) }, + voteGate = { act -> ensureGoogle(act) }, + openPreview = { settings -> openPreview(settings) }, + registerBackHandler = { handler -> overlayBack = handler }, + ) + } + } + } + dialog.setContentView(composeView) + dialog.show() + restoreDownloadNav = dialog.window?.bindPaneNav( + navRegistry, + onDismiss = { if (overlayBack?.invoke() != true) dialog.dismiss() }, + ) + sizeToHost(dialog) + dialog.window?.decorView?.post { sizeToHost(dialog) } + } + + private fun sizeToHost(dialog: Dialog) { + val metrics = activity.resources.displayMetrics + val host = activity.window.decorView + val w = if (host.width > 0) host.width else metrics.widthPixels + val h = if (host.height > 0) host.height else metrics.heightPixels + + var horizontalInset = 0 + var verticalInset = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val insets = activity.windowManager.currentWindowMetrics.windowInsets + val bars = insets.getInsetsIgnoringVisibility(WindowInsets.Type.navigationBars()) + val cutout = insets.getInsetsIgnoringVisibility(WindowInsets.Type.displayCutout()) + val cap = (CUTOUT_PADDING_DP * metrics.density).toInt() + horizontalInset = maxOf( + maxOf(bars.left, cutout.left.coerceAtMost(cap)), + maxOf(bars.right, cutout.right.coerceAtMost(cap)), + ) + verticalInset = maxOf( + maxOf(bars.top, cutout.top.coerceAtMost(cap)), + maxOf(bars.bottom, cutout.bottom.coerceAtMost(cap)), + ) + } + val edge = (EDGE_PADDING_DP * metrics.density).toInt().coerceAtLeast(1) + val maxWidth = (w - (horizontalInset + edge) * 2).coerceAtLeast(1) + val maxHeight = (h - (verticalInset + edge) * 2).coerceAtLeast(1) + + dialog.window?.setLayout( + (w * 0.96f).toInt().coerceAtMost(maxWidth), + (h * 0.92f).toInt().coerceAtMost(maxHeight), + ) + dialog.window?.setGravity(Gravity.CENTER) + } + + private fun openPreview(settings: JSONObject) { + com.winlator.cmod.feature.shortcuts.ShortcutSettingsComposeDialog.preview( + activity, shortcut, settings, + ) { + onConfigApplied() + downloadDialog?.dismiss() + } + } + + private companion object { + const val TAG = "CommunityController" + const val EDGE_PADDING_DP = 12f + const val CUTOUT_PADDING_DP = 8f + } + + private suspend fun applyConfig(settings: JSONObject): List { + val missing = withContext(Dispatchers.IO) { + ComponentChecker.findMissing(activity, contentsManager, settings) + } + if (missing.isEmpty()) { + withContext(Dispatchers.IO) { ConfigApplier.apply(shortcut, settings) } + } + return missing + } +} diff --git a/app/src/main/feature/community/ui/MissingComponentDialog.kt b/app/src/main/feature/community/ui/MissingComponentDialog.kt new file mode 100644 index 000000000..23eb63227 --- /dev/null +++ b/app/src/main/feature/community/ui/MissingComponentDialog.kt @@ -0,0 +1,101 @@ +package com.winlator.cmod.feature.community.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.winlator.cmod.feature.community.ComponentChecker +import com.winlator.cmod.shared.theme.GameSettingsStyle +import com.winlator.cmod.shared.ui.nav.paneNavItem + +@Composable +fun MissingComponentDialog(missing: List, onDismiss: () -> Unit) { + Box( + Modifier.fillMaxWidth().fillMaxHeight().background(Color(0xFF000000).copy(alpha = 0.6f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Column( + Modifier.fillMaxWidth(0.82f).clip(RoundedCornerShape(14.dp)) + .background(GameSettingsStyle.CardSurface) + .border(1.dp, GameSettingsStyle.CardBorder, RoundedCornerShape(14.dp)) + .padding(16.dp) + .clickable(enabled = false) {}, + ) { + Text( + "⚠ Missing Component", + color = GameSettingsStyle.WarningAmber, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + "This config needs components you don't have installed. Install them, then try again:", + color = GameSettingsStyle.TextPrimary, + fontSize = 12.sp, + modifier = Modifier.padding(top = 6.dp), + ) + Column( + Modifier.padding(top = 4.dp).verticalScroll(rememberScrollState()), + ) { + missing.forEach { item -> + Text( + "• ${item.label}", + color = GameSettingsStyle.WarningAmber, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 6.dp), + ) + } + } + Spacer(Modifier.height(14.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Spacer(Modifier.weight(1f)) + Box( + Modifier.clip(RoundedCornerShape(8.dp)) + .background(GameSettingsStyle.AccentBlue.copy(alpha = 0.08f)) + .border( + 1.dp, + GameSettingsStyle.AccentBlue.copy(alpha = 0.25f), + RoundedCornerShape(8.dp), + ) + .paneNavItem( + cornerRadius = 8.dp, + onActivate = onDismiss, + highlightColor = GameSettingsStyle.NavHighlight, + tapToSelect = true, + isEntry = true, + ) + .clickable { onDismiss() } + .padding(horizontal = 16.dp, vertical = 6.dp), + ) { + Text( + "OK", + color = GameSettingsStyle.AccentBlue, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + } + } + } + } +} diff --git a/app/src/main/feature/library/GameSettings.kt b/app/src/main/feature/library/GameSettings.kt index c5a119b11..322184124 100644 --- a/app/src/main/feature/library/GameSettings.kt +++ b/app/src/main/feature/library/GameSettings.kt @@ -201,13 +201,18 @@ class GameSettingsNav { private set var contentResetSignal by mutableStateOf(0) private set + var communityCount by mutableStateOf(0) + var communityCol by mutableStateOf(0) var onSelectSection: ((Int) -> Unit)? = null var onSave: (() -> Unit)? = null var onCancel: (() -> Unit)? = null var onContentBack: (() -> Boolean)? = null + var onCommunityAction: ((Int) -> Unit)? = null val onActionRow: Boolean get() = sidebarIndex >= sidebarCount + val onCommunityRow: Boolean get() = communityCount > 0 && sidebarIndex < 0 + private fun pushContent(dir: Int) { contentDir = dir contentSignal++ @@ -225,15 +230,24 @@ class GameSettingsNav { when (dir) { PANE_DIR_UP -> moveSidebar(-1) PANE_DIR_DOWN -> moveSidebar(1) - PANE_DIR_LEFT -> if (onActionRow && actionCol == 1) actionCol = 0 + PANE_DIR_LEFT -> + if (onCommunityRow) { + if (communityCol > 0) communityCol-- + } else if (onActionRow && actionCol == 1) { + actionCol = 0 + } PANE_DIR_RIGHT -> - if (onActionRow) { + if (onCommunityRow) { + if (communityCol < communityCount - 1) communityCol++ + } else if (onActionRow) { if (actionCol == 0) actionCol = 1 } else { enterContent() } PANE_DIR_ACTIVATE -> - if (onActionRow) { + if (onCommunityRow) { + onCommunityAction?.invoke(communityCol) + } else if (onActionRow) { if (actionCol == 0) onCancel?.invoke() else onSave?.invoke() } else { enterContent() @@ -242,9 +256,10 @@ class GameSettingsNav { } private fun moveSidebar(delta: Int) { - val next = (sidebarIndex + delta).coerceIn(0, sidebarCount) + val lowest = if (communityCount > 0) -1 else 0 + val next = (sidebarIndex + delta).coerceIn(lowest, sidebarCount) sidebarIndex = next - if (next < sidebarCount) onSelectSection?.invoke(next) + if (next in 0 until sidebarCount) onSelectSection?.invoke(next) } fun enterContent() { @@ -270,6 +285,13 @@ class GameSettingsNav { actionCol = col } + fun tapCommunity(col: Int) { + active = false + inContent = false + sidebarIndex = -1 + communityCol = col + } + fun tapContent() { active = false inContent = true @@ -379,6 +401,8 @@ class GameSettingsStateHolder { // Container edits expose container-only fields and hide shortcut fields. val isContainerEditMode = mutableStateOf(false) + + val isPreview = mutableStateOf(false) val wineVersionEditable = mutableStateOf(false) val name = mutableStateOf("") @@ -582,6 +606,9 @@ interface GameSettingsCallbacks { fun onDismiss() fun onAddToHomeScreen() + fun onDownloadCommunityConfig() {} + fun onUploadCommunityConfig() {} + fun onScrapeGameArtwork(gameName: String) {} fun onPickGameCardArtwork() {} fun onRemoveGameCardArtwork() {} @@ -683,17 +710,24 @@ fun GameSettingsContent( ) { val isSteam by state.isSteamGame val isContainer by state.isContainerEditMode + val isPreview by state.isPreview val sections = remember(isSteam, isContainer) { buildSections(isSteam, isContainer) } val selectedIdx by state.currentSection val currentSectionId = sections.getOrNull(selectedIdx)?.first ?: SEC_GENERAL val saveEnabled by state.isLoaded + val showCommunity = !isContainer && !isPreview if (nav != null) { SideEffect { nav.sidebarCount = sections.size + nav.communityCount = if (showCommunity) 2 else 0 nav.onSelectSection = { state.currentSection.intValue = it } nav.onSave = { if (saveEnabled) callbacks.onConfirm() } nav.onCancel = { callbacks.onDismiss() } + nav.onCommunityAction = { col -> + if (col == 0) callbacks.onDownloadCommunityConfig() + else callbacks.onUploadCommunityConfig() + } } } @@ -712,6 +746,10 @@ fun GameSettingsContent( saveEnabled = saveEnabled, onSave = { callbacks.onConfirm() }, onCancel = { callbacks.onDismiss() }, + showCommunity = showCommunity, + onDownloadCommunity = { callbacks.onDownloadCommunityConfig() }, + onUploadCommunity = { callbacks.onUploadCommunityConfig() }, + isPreview = isPreview, nav = nav, modifier = Modifier .width(220.dp) @@ -868,31 +906,55 @@ private fun Sidebar( saveEnabled: Boolean, onSave: () -> Unit, onCancel: () -> Unit, + showCommunity: Boolean = false, + onDownloadCommunity: () -> Unit = {}, + onUploadCommunity: () -> Unit = {}, + isPreview: Boolean = false, nav: GameSettingsNav? = null, modifier: Modifier = Modifier ) { val cancelHighlighted = nav != null && nav.active && !nav.inContent && nav.onActionRow && nav.actionCol == 0 val saveHighlighted = nav != null && nav.active && !nav.inContent && nav.onActionRow && nav.actionCol == 1 + val communityRowActive = nav != null && nav.active && !nav.inContent && nav.onCommunityRow Column( modifier = modifier .background(SidebarBg) .padding(top = 14.dp, bottom = 12.dp) ) { if (title.isNotBlank()) { - Text( - text = title, - color = TextPrimary, - fontSize = SettingLabelSize, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.2.sp, - lineHeight = 15.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .padding(bottom = 10.dp) - ) + .padding(bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + color = TextPrimary, + fontSize = SettingLabelSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.2.sp, + lineHeight = 15.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (isPreview) { + val green = Color(0xFF35C46B) + Box( + modifier = Modifier + .padding(start = 6.dp) + .clip(RoundedCornerShape(6.dp)) + .background(green.copy(alpha = 0.16f)) + .border(1.dp, green.copy(alpha = 0.55f), RoundedCornerShape(6.dp)) + .padding(horizontal = 7.dp, vertical = 3.dp) + ) { + Text("Preview", color = green, fontSize = SettingLabelSize, + fontWeight = FontWeight.SemiBold) + } + } + } Box( modifier = Modifier .padding(horizontal = 12.dp) @@ -910,6 +972,28 @@ private fun Sidebar( .padding(bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(1.dp) ) { + if (showCommunity) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + CommunityHeaderButton( + Icons.Outlined.Download, "Download", + Modifier.weight(1f), + communityRowActive && nav?.communityCol == 0, + { nav?.tapCommunity(0); onDownloadCommunity() } + ) + CommunityHeaderButton( + Icons.Outlined.Upload, "Upload", + Modifier.weight(1f), + communityRowActive && nav?.communityCol == 1, + { nav?.tapCommunity(1); onUploadCommunity() } + ) + } + } sections.forEachIndexed { index, section -> SidebarItem( icon = section.icon, @@ -962,6 +1046,7 @@ private fun Sidebar( height = 30.dp, corner = 8.dp, fontSize = SettingLabelSize, + label = if (isPreview) "Apply" else null, navHighlighted = saveHighlighted, modifier = Modifier.weight(1f) ) @@ -976,6 +1061,7 @@ private fun SaveButton( height: Dp, corner: Dp, fontSize: TextUnit, + label: String? = null, navHighlighted: Boolean = false, modifier: Modifier = Modifier ) { @@ -997,7 +1083,7 @@ private fun SaveButton( contentAlignment = Alignment.Center ) { Text( - stringResource(R.string.common_ui_save), + label ?: stringResource(R.string.common_ui_save), color = if (enabled) AccentBlue else TextDim, fontSize = fontSize, fontWeight = FontWeight.Medium @@ -1005,6 +1091,48 @@ private fun SaveButton( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CommunityHeaderButton( + icon: ImageVector, + label: String, + modifier: Modifier = Modifier, + navHighlighted: Boolean = false, + onClick: () -> Unit +) { + val bringIntoView = remember { BringIntoViewRequester() } + LaunchedEffect(navHighlighted) { + if (navHighlighted) runCatching { bringIntoView.bringIntoView() } + } + Box( + modifier = modifier + .height(28.dp) + .bringIntoViewRequester(bringIntoView) + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, AccentBlue.copy(alpha = 0.25f), RoundedCornerShape(8.dp)) + .background(AccentBlue.copy(alpha = 0.08f)) + .paneHighlight(navHighlighted, cornerRadius = 8.dp, highlightColor = NavHighlight) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + icon, + contentDescription = label, + tint = AccentBlue, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(5.dp)) + Text( + label, + color = AccentBlue, + fontSize = SettingLabelSize, + fontWeight = FontWeight.Medium + ) + } + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun SidebarItem( diff --git a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt index dc3d1ae69..e9a02f953 100644 --- a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt +++ b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt @@ -16,6 +16,8 @@ import android.view.WindowInsets import android.view.WindowManager import android.widget.Toast import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density @@ -42,7 +44,6 @@ import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE import com.winlator.cmod.shared.ui.nav.PaneNavWindowHandlers import com.winlator.cmod.shared.ui.nav.bindPaneNav import androidx.compose.foundation.layout.Box -import androidx.compose.ui.Modifier import androidx.core.net.toUri import com.winlator.cmod.shared.ui.focus.controllerMenuInput import com.winlator.cmod.feature.library.GameSettingsStateHolder @@ -120,6 +121,28 @@ class ShortcutSettingsComposeDialog private constructor( private var contentsManager: ContentsManager = ContentsManager(context) private var isArm64EC = false + private val communityControllerLazy = lazy { + com.winlator.cmod.feature.community.ui.CommunityController( + activity, shortcut, contentsManager + ).also { it.onConfigApplied = { reloadAfterCommunityApply() } } + } + private val communityController get() = communityControllerLazy.value + + private var previewMode = false + private var previewRealShortcut: Shortcut? = null + private var previewTempFile: File? = null + private var previewOnApplied: () -> Unit = {} + private val previewMissing = + mutableStateOf?>(null) + + fun enablePreview(realShortcut: Shortcut, tempFile: File, onApplied: () -> Unit) { + previewMode = true + previewRealShortcut = realShortcut + previewTempFile = tempFile + previewOnApplied = onApplied + state.isPreview.value = true + } + // Preset ID lists (parallel to display name lists) private var box64PresetIds = mutableListOf() @@ -190,7 +213,18 @@ class ShortcutSettingsComposeDialog private constructor( LocalDensity provides Density(defaultDensity.density, fontScale = 1f) ) { val callbacks = createCallbacks() - GameSettingsContent(state = state, callbacks = callbacks, nav = nav) + Box(Modifier) { + GameSettingsContent( + state = state, + callbacks = callbacks, + nav = nav + ) + previewMissing.value?.let { miss -> + com.winlator.cmod.feature.community.ui.MissingComponentDialog(miss) { + previewMissing.value = null + } + } + } } } } @@ -210,6 +244,10 @@ class ShortcutSettingsComposeDialog private constructor( private fun createCallbacks(): GameSettingsCallbacks { return object : GameSettingsCallbacks { override fun onConfirm() { + if (previewMode) { + applyPreview() + return + } saveSettings() emitLibraryRefreshIfNeeded() dismiss() @@ -219,6 +257,15 @@ class ShortcutSettingsComposeDialog private constructor( dismiss() } + override fun onDownloadCommunityConfig() { + communityController.openDownload() + } + + override fun onUploadCommunityConfig() { + saveSettings() + communityController.upload() + } + override fun onAddToHomeScreen() { val result = if (fragment != null) { fragment.addShortcutToScreen(shortcut) @@ -358,6 +405,33 @@ class ShortcutSettingsComposeDialog private constructor( } } + + private fun reloadAfterCommunityApply() { + loadInitialData() + loadResourceArrays() + loadContentsAsync() + shouldRefreshLibraryOnSave = true + } + + private fun applyPreview() { + saveSettings() + val real = previewRealShortcut ?: return + val edited = com.winlator.cmod.feature.community.ConfigSerializer.serialize(shortcut) + CoroutineScope(Dispatchers.IO).launch { + val miss = com.winlator.cmod.feature.community.ComponentChecker + .findMissing(context, contentsManager, edited) + withContext(Dispatchers.Main) { + if (miss.isEmpty()) { + com.winlator.cmod.feature.community.ConfigApplier.apply(real, edited) + previewOnApplied() + dismiss() + } else { + previewMissing.value = miss + } + } + } + } + private fun loadInitialData() { val container = shortcut.container @@ -2481,6 +2555,8 @@ class ShortcutSettingsComposeDialog private constructor( } fun dismiss() { + if (communityControllerLazy.isInitialized()) communityControllerLazy.value.dispose() + if (previewMode) runCatching { previewTempFile?.delete() } AppUtils.hideKeyboard(activity) dialog.dismiss() } @@ -2489,6 +2565,24 @@ class ShortcutSettingsComposeDialog private constructor( private const val TAG = "ShortcutSettingsCompose" private const val EXTRA_USE_CONTAINER_DEFAULTS = "use_container_defaults" + @JvmStatic + fun preview( + activity: Activity, + realShortcut: Shortcut, + communitySettings: org.json.JSONObject, + onApplied: () -> Unit, + ) { + val tempDir = File(activity.cacheDir, "wn_preview").apply { mkdirs() } + runCatching { tempDir.listFiles()?.forEach { it.delete() } } + val tempFile = File(tempDir, realShortcut.file.name) + FileUtils.copy(realShortcut.file, tempFile) + val temp = Shortcut(realShortcut.container, tempFile) + com.winlator.cmod.feature.community.ConfigApplier.apply(temp, communitySettings) + val dlg = ShortcutSettingsComposeDialog(activity, temp) + dlg.enablePreview(realShortcut, tempFile, onApplied) + dlg.show() + } + /** * Creates a minimal `.desktop` file on the preferred game container and returns a * [Shortcut] pointing at it. Used when the user taps Settings on a library game diff --git a/app/src/main/res/values-v27/styles.xml b/app/src/main/res/values-v27/styles.xml index eb93afb21..6fe92f675 100644 --- a/app/src/main/res/values-v27/styles.xml +++ b/app/src/main/res/values-v27/styles.xml @@ -1,20 +1,23 @@ - - - - - - - - + + + + + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index c241308b6..761cc5bbf 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -155,7 +155,9 @@ true -