diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index db628d06d..f1f39ce82 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -90,6 +90,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 c5ba8da09..154773f16 100644 --- a/.github/workflows/tag-apk-artifacts.yml +++ b/.github/workflows/tag-apk-artifacts.yml @@ -84,6 +84,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 7e71a9864..da62a3854 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ signing.properties References/ *.hprof android_sysvshm/build64/ + +# Community config HMAC secret (never commit) +tools/community_hmac.secret diff --git a/app/build.gradle b/app/build.gradle index 12c190c14..6fa9d15b8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -77,6 +77,16 @@ def appVersionName = providers.gradleProperty("VERSION_NAME") def coldClientVersionFile = rootProject.file("tools/gbe_fork.version") def coldClientVersion = coldClientVersionFile.exists() ? coldClientVersionFile.text.trim() : "unknown" +// Community config-sharing HMAC secret (git-ignored file) + API base URL. +// The secret is NEVER committed; it lives only in tools/community_hmac.secret +// (or the COMMUNITY_HMAC_SECRET env var) and is embedded in the signed APK. +def communityHmacFile = rootProject.file("tools/community_hmac.secret") +def communityHmacSecret = communityHmacFile.exists() ? communityHmacFile.text.trim() + : (System.getenv("COMMUNITY_HMAC_SECRET") ?: "") +def communityApiBase = providers.gradleProperty("COMMUNITY_API_BASE") + .orElse(providers.environmentVariable("COMMUNITY_API_BASE")) + .getOrElse("https://api.winnative.dev/api/v1/") + android { namespace 'com.winlator.cmod' compileSdk 35 @@ -104,6 +114,8 @@ android { resConfigs "en", "da", "de", "es", "fr", "hi", "it", "ko", "pl", "pt-rBR", "ro", "ru", "uk", "zh-rCN", "zh-rTW" buildConfigField("String", "COLD_CLIENT_VERSION", "\"${coldClientVersion}\"") + buildConfigField("String", "COMMUNITY_HMAC_SECRET", "\"${communityHmacSecret}\"") + buildConfigField("String", "COMMUNITY_API_BASE", "\"${communityApiBase}\"") } androidResources { 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/ComponentChecker.kt b/app/src/main/feature/community/ComponentChecker.kt new file mode 100644 index 000000000..2bd465773 --- /dev/null +++ b/app/src/main/feature/community/ComponentChecker.kt @@ -0,0 +1,122 @@ +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.system.GPUInformation +import com.winlator.cmod.runtime.wine.WineInfo +import org.json.JSONObject + +/** + * Compares the components a downloaded config needs (Wine, DXVK, VKD3D, Box64, + * FEXCore) against what is actually installed via [ContentsManager], so the UI + * can show a "⚠ MISSING COMPONENT" warning before applying. + * + * Matching is deliberately lenient (treats anything it can't positively prove + * missing as present) to avoid false positives that would block a valid apply. + */ +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() + + // Wine / Proton + 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) + // WineInfo resolves bundled/main versions; only flag if clearly a + // managed profile name that isn't installed. + val resolved = runCatching { + WineInfo.fromIdentifier(context, contentsManager, wineVer) + }.getOrNull() + if (resolved == null && !matches(wineVer, installed) && installed.isNotEmpty()) { + missing += Missing("Wine $wineVer") + } + } + + // DXVK / VKD3D from dxwrapperConfig + 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) + } + + // Box64 / WOWBox64 + checkVersion(settings.optString("box64Version", ""), "Box64", + installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_BOX64) + + installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_WOWBOX64), missing) + + // FEXCore + checkVersion(settings.optString("fexcoreVersion", ""), "FEXCore", + installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_FEXCORE), missing) + + // Graphics driver version (built-in wrapper version or installed custom + // adrenotools driver). The `version=` token in graphicsDriverConfig is + // the selected driver; if it's not available here, the config can't be + // applied faithfully -> report it. + 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\"") + } + + return missing + } + + private fun graphicsDriverAvailable(context: Context, version: String): Boolean { + // Built-in / system wrapper versions (filtered by GPU support). + 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) + } + } + // Installed custom adrenotools drivers (the folder id == the version token). + 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 (installed.isEmpty()) { + // Nothing of this type installed at all -> it's needed but absent. + out += Missing("$label $v") + } else 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() + return installed.any { name -> + val n = name.lowercase() + n == v || n.endsWith(v) || n.contains(v) || 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..7aee9bce8 --- /dev/null +++ b/app/src/main/feature/community/ConfigApplier.kt @@ -0,0 +1,42 @@ +package com.winlator.cmod.feature.community + +import com.winlator.cmod.runtime.container.Shortcut +import org.json.JSONObject + +/** + * Applies a downloaded settings document to the CURRENT shortcut only, as + * shortcut-level overrides. It never mutates the Container, other shortcuts, the + * Exec line, icon, artwork, or container_id -- so containers can't be broken. + * EVERY known setting is applied verbatim (including the graphics-driver version + * and dxvk/vkd3d/gpuName tokens); availability is gated beforehand by + * ComponentChecker, which blocks apply + shows MISSING COMPONENT if anything the + * config needs isn't installed. + */ +object ConfigApplier { + + /** The only keys an applied config may write. Mirrors the serializer. */ + private val ALLOWED = setOf( + "screenSize", "audioDriver", "midiSoundFont", "graphicsDriver", + "graphicsDriverConfig", "dxwrapper", "dxwrapperConfig", "swapRB", + "refreshRate", "fpsLimit", "sgsrEnabled", "sgsrUpscaleMode", "sgsrSharpness", + "wineVersion", "emulator", "emulator64", "lc_all", "desktopTheme", + "wincomponents", "envVars", "box64Version", "box64Preset", + "fexcoreVersion", "fexcorePreset", "startupSelection", "execArgs", + "fullscreenStretched", "cpuList", "cpuListWoW64", "inputType", + "exclusiveXInput", "numControllers", "disableXinput", "simTouchScreen", + "useColdClient", "unpackFiles", "useSteamInput", "steamOfflineMode", + "runtimePatcher", + ) + + fun apply(shortcut: Shortcut, settings: JSONObject) { + val keys = settings.keys() + while (keys.hasNext()) { + val key = keys.next() + if (key !in ALLOWED) continue + shortcut.putExtra(key, settings.optString(key, "")) + } + // Force shortcut-level resolution so the applied overrides take effect. + 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..29053c801 --- /dev/null +++ b/app/src/main/feature/community/ConfigSerializer.kt @@ -0,0 +1,106 @@ +package com.winlator.cmod.feature.community + +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.Shortcut +import org.json.JSONObject + +/** + * Serializes the *effective* value of every portable shortcut setting (resolving + * shortcut override -> container default via [Shortcut.getSettingExtra]) into a + * settings map for sharing. Device-local driver tokens (version=/gpuName=) are + * blanked so a downloader keeps their own GPU driver. PII / structural / local + * fields (paths, uuid, container_id, controlsProfile, artwork) are excluded. + */ +object ConfigSerializer { + + fun serialize(shortcut: Shortcut): JSONObject { + val c = shortcut.container + val s = JSONObject() + + fun put(key: String, value: String?) { + if (!value.isNullOrBlank()) s.put(key, value) + } + fun eff(key: String, containerValue: String): String = + shortcut.getSettingExtra(key, containerValue) + fun extra(key: String, def: String): String = shortcut.getExtra(key, def) + + // General / Display (explicit Java getter calls to avoid Kotlin + // property-name ambiguity for ALL-CAPS getters like getDXWrapper). + put("screenSize", eff("screenSize", c.getScreenSize())) + put("audioDriver", eff("audioDriver", c.getAudioDriver())) + put("midiSoundFont", eff("midiSoundFont", c.getMIDISoundFont())) + put("graphicsDriver", eff("graphicsDriver", c.getGraphicsDriver())) + put("graphicsDriverConfig", eff("graphicsDriverConfig", c.getGraphicsDriverConfig())) + put("dxwrapper", eff("dxwrapper", c.getDXWrapper())) + put("dxwrapperConfig", eff("dxwrapperConfig", c.getDXWrapperConfig())) + put("swapRB", extra("swapRB", "")) + put("refreshRate", extra("refreshRate", "")) + put("fpsLimit", extra("fpsLimit", "")) + put("sgsrEnabled", extra("sgsrEnabled", "")) + put("sgsrUpscaleMode", extra("sgsrUpscaleMode", "")) + put("sgsrSharpness", extra("sgsrSharpness", "")) + + // Wine + put("wineVersion", eff("wineVersion", c.getWineVersion())) + put("emulator", eff("emulator", c.getEmulator())) + put("emulator64", eff("emulator64", c.getEmulator64())) + put("lc_all", eff("lc_all", c.getLC_ALL())) + put("desktopTheme", eff("desktopTheme", c.getDesktopTheme())) + + // Components / Variables + put("wincomponents", eff("wincomponents", c.getWinComponents())) + put("envVars", eff("envVars", c.getEnvVars())) + + // Advanced + put("box64Version", eff("box64Version", c.getBox64Version())) + put("box64Preset", eff("box64Preset", c.getBox64Preset())) + put("fexcoreVersion", eff("fexcoreVersion", c.getFEXCoreVersion())) + put("fexcorePreset", eff("fexcorePreset", c.getFEXCorePreset())) + put("startupSelection", eff("startupSelection", c.getStartupSelection().toString())) + put("execArgs", eff("execArgs", c.getExecArgs())) + put("fullscreenStretched", eff("fullscreenStretched", if (c.isFullscreenStretched()) "1" else "0")) + put("cpuList", eff("cpuList", c.getCPUList(true))) + put("cpuListWoW64", eff("cpuListWoW64", c.getCPUListWoW64(true))) + + // Input + put("inputType", eff("inputType", c.getInputType().toString())) + put("exclusiveXInput", eff("exclusiveXInput", if (c.isExclusiveXInput()) "1" else "0")) + put("numControllers", extra("numControllers", "")) + put("disableXinput", extra("disableXinput", "")) + put("simTouchScreen", extra("simTouchScreen", "")) + + // Steam (only for Steam games) + if (isSteam(shortcut)) { + put("useColdClient", eff("useColdClient", if (c.isUseColdClient()) "1" else "0")) + put("unpackFiles", eff("unpackFiles", if (c.isUnpackFiles()) "1" else "0")) + put("useSteamInput", extra("useSteamInput", "")) + put("steamOfflineMode", eff("steamOfflineMode", if (c.isSteamOfflineMode()) "1" else "0")) + put("runtimePatcher", eff("runtimePatcher", if (c.isRuntimePatcher()) "1" else "0")) + } + return s + } + + 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 isSteam(shortcut: Shortcut): Boolean = + shortcut.getExtra("game_source", "").equals("steam", ignoreCase = true) + + 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..ada66dd13 --- /dev/null +++ b/app/src/main/feature/community/DeviceIdentity.kt @@ -0,0 +1,69 @@ +package com.winlator.cmod.feature.community + +import android.os.Build + +/** + * Builds the hardware-identity block used by the community filters. + * + * - The "Chipset" filter keys on the SoC model (e.g. "SM8750"), which is + * identical across every brand/region/model that ships that chip. + * - The "Device" filter keys (server-side) on a canonical device derived from + * brand + codename/model, unifying regional variants of the same phone. + * + * SOC_MODEL/SOC_MANUFACTURER are API 31+; below that we fall back to getprop. + */ +object DeviceIdentity { + + 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, + ) + + fun current(): HardwareBlock { + val soc = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + Build.SOC_MODEL.orEmptyClean() else getprop("ro.soc.model") + val socMfr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + Build.SOC_MANUFACTURER.orEmptyClean() else getprop("ro.soc.manufacturer") + val board = getprop("ro.board.platform") + val market = listOf( + getprop("ro.product.marketname"), + getprop("ro.product.odm.marketname"), + getprop("ro.config.marketing_name"), + getprop("ro.product.vendor.marketname"), + ).firstOrNull { it.isNotBlank() } ?: "" + return HardwareBlock( + socModel = soc.ifBlank { board }.take(48), + socManufacturer = socMfr.take(48), + boardPlatform = board.take(48), + deviceCodename = (Build.DEVICE ?: getprop("ro.product.device")).clean().take(48), + modelNumber = (Build.MODEL ?: getprop("ro.product.model")).clean().take(48), + modelRegion = getprop("ro.product.name").take(48), + brand = (Build.BRAND ?: getprop("ro.product.brand")).clean().take(48), + marketName = market.take(64), + ) + } + + /** Stable key sent for the default "Chipset" filter. */ + fun chipsetKey(): String { + val hw = current() + return hw.socModel.ifBlank { hw.boardPlatform } + } + + 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 } + + private fun String?.orEmptyClean(): String = this.clean() +} diff --git a/app/src/main/feature/community/UploaderIdentity.kt b/app/src/main/feature/community/UploaderIdentity.kt new file mode 100644 index 000000000..c0d6eeb7c --- /dev/null +++ b/app/src/main/feature/community/UploaderIdentity.kt @@ -0,0 +1,131 @@ +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 + +/** + * Provides the opaque uploader handle used for ownership + one-vote-per-user. + * + * The handle is a SHA-256 hash of either the Google Play Games player id (when + * signed in) or a per-install random UUID stored in EncryptedSharedPreferences. + * The raw player id / UUID is NEVER sent or displayed; only this hash is, and + * the server further hashes it again for the public "WN-XXXX" badge. So the + * displayed token reveals nothing and the handle itself is unguessable. + */ +object UploaderIdentity { + + @Volatile + private var cachedGoogleId: String? = null + + @Volatile + private var cachedDeviceUuid: String? = null + + @Volatile + private var cachedDisplayName: String? = null + + /** Current opaque handle (hex). Prefers a resolved Google id, else device UUID. */ + fun handle(context: Context): String { + val base = cachedGoogleId ?: deviceUuid(context) + return sha256Hex("wn1:$base") + } + + /** True if the handle is currently backed by a Google account. */ + fun isGoogleBacked(): Boolean = cachedGoogleId != null + + /** + * The signed-in Google Play Games display name (gamer tag), or "" if not + * Google-backed. Sent on upload so admins/mods can identify the uploader. + * This is the Play Games alias, not the Google account email. + */ + fun displayName(): String = cachedDisplayName ?: "" + + /** + * Best-effort async resolution of the Google Play Games player id. Call when + * the community feature is opened; the result is cached for subsequent + * handle() calls. Falls back silently to the device UUID if unavailable. + */ + 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) } + } + + /** + * Interactively prompt Google Play Games sign-in (if not already signed in), + * then resolve the player id. Invokes [onDone] with true once a Google-backed + * handle is available, false otherwise. Required before upload/vote. + */ + fun signInAndResolve(activity: Activity, 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 { + 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) { + // Fall back to plain prefs (UUID is not sensitive on its own). + 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..65521a08a --- /dev/null +++ b/app/src/main/feature/community/net/CommunityApiClient.kt @@ -0,0 +1,129 @@ +package com.winlator.cmod.feature.community.net + +import android.content.Context +import com.winlator.cmod.BuildConfig +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.Companion.toHttpUrl +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 + +/** + * OkHttp client for the community config API. Every request carries the HMAC + * headers from [RequestSigner]. All calls are suspending and run on the IO + * dispatcher. Non-2xx responses throw [IOException] with the server's detail. + */ +class CommunityApiClient(private val context: Context) { + + private val client = OkHttpClient.Builder() + .connectTimeout(8, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + + private val base = BuildConfig.COMMUNITY_API_BASE.toHttpUrl() + private val json = Json { ignoreUnknownKeys = true } + private val JSON_MEDIA = "application/json".toMediaType() + + suspend fun listConfigs( + gameKey: String, + filter: CommunityFilter, + hw: DeviceIdentity.HardwareBlock, + ): ListResponse = withContext(Dispatchers.IO) { + val url = base.newBuilder() + .addPathSegment("configs") + .addQueryParameter("gameKey", gameKey) + .addQueryParameter("filter", filter.wire) + .addQueryParameter("soc", hw.socModel.ifBlank { 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 = base.newBuilder().addPathSegment("configs").addPathSegment(id).build() + val obj = JSONObject(exec("GET", url, null)) + obj.optJSONObject("settings") ?: JSONObject() + } + + suspend fun upload( + gameKey: String, + store: String, + settings: JSONObject, + hw: DeviceIdentity.HardwareBlock, + ): UploadResult = withContext(Dispatchers.IO) { + val payload = JSONObject() + .put("schemaVersion", 1) + .put("gameKey", gameKey) + .put("store", store) + // Play Games display name, shown only in admin/mod views for moderation. + .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)) + val url = base.newBuilder().addPathSegment("configs").build() + json.decodeFromString(UploadResult.serializer(), + exec("POST", url, payload.toString().toByteArray())) + } + + suspend fun deleteConfig(id: String): Boolean = withContext(Dispatchers.IO) { + val url = base.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 = base.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 = base.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: okhttp3.HttpUrl, body: ByteArray?): String { + val bodyBytes = body ?: ByteArray(0) + // Resolve identity per-request so it reflects a Google sign-in that may + // have completed after this client was constructed. + 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.body?.string() ?: "" + if (!resp.isSuccessful) { + val detail = runCatching { JSONObject(text).optString("detail") }.getOrDefault("") + throw IOException(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..f25373ee7 --- /dev/null +++ b/app/src/main/feature/community/net/CommunityModels.kt @@ -0,0 +1,46 @@ +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 = "", +) + +@Serializable +data class UploadResult( + val id: String = "", + val exportName: String = "", +) + +@Serializable +data class VoteResult( + val up: Int = 0, + val down: Int = 0, + val myVote: Int = 0, +) + +/** Filter modes for the download list. */ +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..8c805acff --- /dev/null +++ b/app/src/main/feature/community/net/RequestSigner.kt @@ -0,0 +1,48 @@ +package com.winlator.cmod.feature.community.net + +import com.winlator.cmod.BuildConfig +import java.security.MessageDigest +import java.util.UUID +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * Produces the HMAC authentication headers for every API request. The signature + * is computed with a secret embedded in the signed release APK over: + * METHOD \n PATH \n TIMESTAMP \n NONCE \n SHA256(body) + * matching the server's verification in auth.py. + */ +object RequestSigner { + + private val secret: ByteArray = BuildConfig.COMMUNITY_HMAC_SECRET.toByteArray() + + fun headers( + method: String, + path: String, + body: ByteArray, + uploaderHandle: String, + googleBacked: Boolean, + ): Map { + 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, + // Identity class: gates upload/vote and ties bans to a Google account. + "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..cdf2d47fa --- /dev/null +++ b/app/src/main/feature/community/ui/CommunityConfigDownloadDialog.kt @@ -0,0 +1,337 @@ +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.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxHeight +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +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.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 kotlinx.coroutines.launch +import org.json.JSONObject + +private val Bg = Color(0xFF18181D) +private val Card = Color(0xFF1C1C2A) +private val CardBorder = Color(0xFF2A2A3A) +private val Accent = Color(0xFF1A9FFF) +private val TextPrimary = Color(0xFFF0F4FF) +private val TextSecondary = Color(0xFF7A8FA8) +private val TextDim = Color(0xFF6E7681) +private val Up = Color(0xFF4CD07D) +private val Down = Color(0xFFFF6B6B) + +/** + * Community config browser for ONE game. Lists only this game's configs, filter + * defaulting to Chipset (same SoC across brands/regions), sorted by the server's + * vote rule. Rows allow one vote, report, owner-delete, and tap-to-apply (with a + * MISSING COMPONENT guard). + */ +@Composable +fun CommunityConfigDownloadScreen( + gameTitle: String, + gameKey: String, + hw: DeviceIdentity.HardwareBlock, + api: CommunityApiClient, + applyConfig: suspend (JSONObject) -> List, + onAppliedDismiss: () -> Unit, + onClose: () -> Unit, + toast: (String) -> Unit, + voteGate: (() -> Unit) -> Unit = { it() }, + openPreview: (JSONObject) -> 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 missing by remember { mutableStateOf?>(null) } + var reportTarget by remember { mutableStateOf(null) } + + suspend fun reload() { + loading = true; error = null + runCatching { api.listConfigs(gameKey, filter, hw) } + .onSuccess { configs = it.configs; deviceDisplay = it.deviceDisplay; 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() } + } + } + + // The host window is sized explicitly (~97% of the screen), so the panel + // simply fills it — nearly as wide as Shortcut Settings, with room for two + // configs across. + Box( + Modifier.fillMaxWidth().fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier.fillMaxWidth().fillMaxHeight().clip(RoundedCornerShape(16.dp)) + .background(Bg).border(1.dp, CardBorder, RoundedCornerShape(16.dp)).padding(16.dp), + ) { + // Header: title + game name, then filters, then Close on the right. + 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) { 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) { onClose() } + } + val sub = when (filter) { + CommunityFilter.CHIPSET -> "Chipset: ${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) + // Two configs across; scroll down through the rest. + 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 -> + // Voting requires a Google-backed identity. + 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 = { + scope.launch { + runCatching { api.deleteConfig(cfg.id) } + .onSuccess { configs = configs.filterNot { c -> c.id == cfg.id } + toast("Deleted") } + .onFailure { toast(it.message ?: "Delete failed") } + } + }, + ) + } + } + } + } + } + } + + missing?.let { MissingComponentDialog(it) { missing = 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).clickable { onApply() }) { + 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)) + // FlowRow so the actions wrap within the narrower grid-cell width. + @OptIn(ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Pill("Apply", Accent) { onApply() } + Pill("Report", TextSecondary) { onReport() } + Pill("Preview", Accent) { onPreview() } + if (cfg.ownedByMe) Pill("Delete", Down) { 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 Color(0xFF171722)) + .border(1.dp, if (active) color.copy(alpha = 0.5f) else CardBorder, RoundedCornerShape(8.dp)) + .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, onClick: () -> Unit) { + Box( + Modifier.clip(RoundedCornerShape(8.dp)) + .background(if (selected) Accent.copy(alpha = 0.12f) else Color(0xFF171722)) + .border(1.dp, if (selected) Accent.copy(alpha = 0.5f) else CardBorder, RoundedCornerShape(8.dp)) + .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, 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)) + .clickable { onClick() }.padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text(label, color = tint, 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 ReportDialog(onSubmit: (String) -> Unit, onCancel: () -> Unit) { + var reason by remember { mutableStateOf("") } + Box(Modifier.fillMaxWidth().fillMaxHeight().background(Color(0x99000000)).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(Color(0xFF171722)).border(1.dp, CardBorder, RoundedCornerShape(8.dp)) + .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(), + ) + 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) { onCancel() } + Pill("Submit", Accent) { 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..3ea530dde --- /dev/null +++ b/app/src/main/feature/community/ui/CommunityController.kt @@ -0,0 +1,200 @@ +package com.winlator.cmod.feature.community.ui + +import android.app.Activity +import android.app.Dialog +import android.view.ViewGroup +import android.view.Window +import android.view.WindowManager +import android.widget.Toast +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +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 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 + +/** + * Orchestrates the community Download/Upload features for the current shortcut. + * Owns the API client (bound to the opaque uploader handle), hosts the download + * dialog, runs uploads, and gates apply behind the MISSING-COMPONENT check. + */ +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 + + /** Set by the host so the open settings UI reloads after a config is applied. */ + var onConfigApplied: () -> Unit = {} + + init { + // Best-effort upgrade of the handle to the Google account, if signed in. + UploaderIdentity.resolveGoogle(activity) + } + + /** Upload the (already-saved) current shortcut as a community config. */ + fun upload() { + ensureGoogle { + // Immediate feedback that the tap registered (the upload is async). + toast("Uploading…") + scope.launch { + runCatching { + val settings = ConfigSerializer.serialize(shortcut) + val gameKey = ConfigSerializer.gameKey(shortcut) + val store = ConfigSerializer.storeOf(shortcut) + val hw = DeviceIdentity.current() + api.upload(gameKey, store, settings, hw) + }.onSuccess { toast("Upload successful") } + .onFailure { + toast("Upload failed: ${it.message ?: "unknown error"}") + } + } + } + } + + /** + * Ensure a Google-backed identity (required for upload/vote so that bans can + * be tied to a Google account), prompting Play Games sign-in if needed, then + * run [action]. Shows a clear message if the user declines. + */ + fun ensureGoogle(action: () -> Unit) { + if (UploaderIdentity.isGoogleBacked()) { + action() + return + } + toast("Sign in with a Google account…") + UploaderIdentity.signInAndResolve(activity) { ok -> + if (ok) action() + else toast("Google sign-in is required to upload or vote") + } + } + + /** Cancel in-flight work. Call when the host dialog is dismissed. */ + fun dispose() { + scope.cancel() + } + + /** + * System toast for user feedback. Used instead of the app's WinToast because + * WinToast anchors its popup to the Activity window, which is occluded by the + * Shortcut-Settings dialog; a system toast always renders on top. + */ + private fun toast(msg: String) { + activity.runOnUiThread { Toast.makeText(activity, msg, Toast.LENGTH_LONG).show() } + } + + /** Open the community config browser for this game. */ + fun openDownload() { + val lifecycleOwner = activity as? LifecycleOwner + val savedStateOwner = activity as? SavedStateRegistryOwner + if (lifecycleOwner == null || savedStateOwner == null) { + toast("Community sharing is unavailable here") + return + } + val gameKey = ConfigSerializer.gameKey(shortcut) + val hw = DeviceIdentity.current() + 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(android.view.Gravity.CENTER) + setDimAmount(0.5f) + addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) + } + } + 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, + 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) }, + ) + } + } + } + dialog.setContentView(composeView) + dialog.show() + // CRITICAL: the floating ContentDialog ignores setLayout() called BEFORE + // show() (it wraps content and squishes). Size it AFTER show, and again + // post-attach — exactly how ShortcutSettingsComposeDialog sizes itself. + sizeToHost(dialog) + dialog.window?.decorView?.post { sizeToHost(dialog) } + } + + /** Size a dialog window to ~the host activity's window (like Shortcut Settings). */ + private fun sizeToHost(dialog: Dialog) { + val host = activity.window.decorView + val w = (if (host.width > 0) host.width + else activity.resources.displayMetrics.widthPixels) + val h = (if (host.height > 0) host.height + else activity.resources.displayMetrics.heightPixels) + dialog.window?.setLayout((w * 0.96f).toInt(), (h * 0.92f).toInt()) + dialog.window?.setGravity(android.view.Gravity.CENTER) + } + + /** + * Open the full Shortcut-Settings UI in PREVIEW mode for a fetched config. + * On Apply, it writes to the real shortcut, reloads the open settings UI, and + * closes the community browser. + */ + private fun openPreview(settings: JSONObject) { + com.winlator.cmod.feature.shortcuts.ShortcutSettingsComposeDialog.preview( + activity, shortcut, settings, + ) { + onConfigApplied() + downloadDialog?.dismiss() + } + } + + 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..3272cbc9b --- /dev/null +++ b/app/src/main/feature/community/ui/MissingComponentDialog.kt @@ -0,0 +1,46 @@ +package com.winlator.cmod.feature.community.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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 + +/** + * "⚠ MISSING COMPONENT" dialog. Lists the components the downloaded config needs + * that are not installed. A single OK button just dismisses (per spec) — the + * config is NOT applied when components are missing. + */ +@Composable +fun MissingComponentDialog(missing: List, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = Color(0xFF1C1C2A), + titleContentColor = Color(0xFFFFB74D), + textContentColor = Color(0xFFF0F4FF), + title = { Text("⚠ Missing Component", fontWeight = FontWeight.SemiBold) }, + text = { + Column { + Text( + "This config needs components you don't have installed. Install them, " + + "then try again:", + fontSize = 13.sp, + ) + missing.forEach { m -> + Text("• ${m.label}", fontSize = 13.sp, fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 6.dp), color = Color(0xFFFFB74D)) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("OK", color = Color(0xFF1A9FFF)) } + }, + ) +} diff --git a/app/src/main/feature/library/GameSettings.kt b/app/src/main/feature/library/GameSettings.kt index 2b68d5da9..a8c5e306b 100644 --- a/app/src/main/feature/library/GameSettings.kt +++ b/app/src/main/feature/library/GameSettings.kt @@ -200,6 +200,10 @@ class GameSettingsStateHolder { // Container edits expose container-only fields and hide shortcut fields. val isContainerEditMode = mutableStateOf(false) + + // Preview mode: rendering a community config (not a saved shortcut). Shows a + // green "Preview" badge and turns Save into Apply. + val isPreview = mutableStateOf(false) val wineVersionEditable = mutableStateOf(false) val name = mutableStateOf("") @@ -398,6 +402,11 @@ interface GameSettingsCallbacks { fun onDismiss() fun onAddToHomeScreen() + // Community config sharing (header buttons). Default no-ops so other + // implementers are unaffected. + fun onDownloadCommunityConfig() {} + fun onUploadCommunityConfig() {} + fun onPickGameCardArtwork() {} fun onRemoveGameCardArtwork() {} fun onPickGridArtwork() {} @@ -500,6 +509,7 @@ 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 @@ -520,6 +530,10 @@ fun GameSettingsContent( saveEnabled = saveEnabled, onSave = { callbacks.onConfirm() }, onCancel = { callbacks.onDismiss() }, + showCommunity = !isContainer && !isPreview, + onDownloadCommunity = { callbacks.onDownloadCommunityConfig() }, + onUploadCommunity = { callbacks.onUploadCommunityConfig() }, + isPreview = isPreview, modifier = Modifier .width(220.dp) .fillMaxHeight() @@ -599,6 +613,10 @@ private fun Sidebar( saveEnabled: Boolean, onSave: () -> Unit, onCancel: () -> Unit, + showCommunity: Boolean = false, + onDownloadCommunity: () -> Unit = {}, + onUploadCommunity: () -> Unit = {}, + isPreview: Boolean = false, modifier: Modifier = Modifier ) { Column( @@ -606,22 +624,60 @@ private fun Sidebar( .background(SidebarBg) .padding(top = 14.dp, bottom = 12.dp) ) { - // Header: shortcut/game title being edited + // Header: shortcut/game title being edited (+ green Preview badge) 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) + } + } + } + // Community config sharing: Download / Upload for this game. + if (showCommunity) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + CommunityHeaderButton( + Icons.Outlined.Download, "Download", + Modifier.weight(1f), onDownloadCommunity + ) + CommunityHeaderButton( + Icons.Outlined.Upload, "Upload", + Modifier.weight(1f), onUploadCommunity + ) + } + Spacer(Modifier.height(8.dp)) + } Box( modifier = Modifier .padding(horizontal = 12.dp) @@ -690,6 +746,7 @@ private fun Sidebar( height = 30.dp, corner = 8.dp, fontSize = SettingLabelSize, + label = if (isPreview) "Apply" else null, modifier = Modifier.weight(1f) ) } @@ -703,6 +760,7 @@ private fun SaveButton( height: Dp, corner: Dp, fontSize: TextUnit, + label: String? = null, modifier: Modifier = Modifier ) { Box( @@ -722,7 +780,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 @@ -730,6 +788,40 @@ private fun SaveButton( } } +@Composable +private fun CommunityHeaderButton( + icon: ImageVector, + label: String, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Box( + modifier = modifier + .height(28.dp) + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, AccentBlue.copy(alpha = 0.25f), RoundedCornerShape(8.dp)) + .background(AccentBlue.copy(alpha = 0.08f)) + .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 + ) + } + } +} + @Composable private fun SidebarItem( icon: ImageVector, diff --git a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt index da895c739..7045514ae 100644 --- a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt +++ b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt @@ -18,7 +18,10 @@ import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box 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 @@ -106,6 +109,33 @@ class ShortcutSettingsComposeDialog private constructor( private var contentsManager: ContentsManager = ContentsManager(context) private var isArm64EC = false + // Community config sharing controller (Download/Upload header buttons). + private val communityControllerLazy = lazy { + com.winlator.cmod.feature.community.ui.CommunityController( + activity, shortcut, contentsManager + ).also { it.onConfigApplied = { reloadAfterCommunityApply() } } + } + private val communityController get() = communityControllerLazy.value + + // Preview mode: this dialog edits a TEMP shortcut seeded from a community + // config; Apply writes the (edited) settings to the real shortcut. The temp + // file lives in cacheDir and is deleted on dismiss. The real shortcut and + // stored community config are untouched unless the user taps Apply. + 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() @@ -181,10 +211,17 @@ class ShortcutSettingsComposeDialog private constructor( CompositionLocalProvider( LocalDensity provides Density(defaultDensity.density, fontScale = 1f) ) { - GameSettingsContent( - state = state, - callbacks = createCallbacks() - ) + Box(Modifier) { + GameSettingsContent( + state = state, + callbacks = createCallbacks() + ) + previewMissing.value?.let { miss -> + com.winlator.cmod.feature.community.ui.MissingComponentDialog(miss) { + previewMissing.value = null + } + } + } } } } @@ -204,6 +241,10 @@ class ShortcutSettingsComposeDialog private constructor( private fun createCallbacks(): GameSettingsCallbacks { return object : GameSettingsCallbacks { override fun onConfirm() { + if (previewMode) { + applyPreview() + return + } saveSettings() emitLibraryRefreshIfNeeded() dismiss() @@ -213,6 +254,16 @@ class ShortcutSettingsComposeDialog private constructor( dismiss() } + override fun onDownloadCommunityConfig() { + communityController.openDownload() + } + + override fun onUploadCommunityConfig() { + // Upload implies Save so we share the persisted, current settings. + saveSettings() + communityController.upload() + } + override fun onAddToHomeScreen() { val result = if (fragment != null) { fragment.addShortcutToScreen(shortcut) @@ -341,6 +392,38 @@ class ShortcutSettingsComposeDialog private constructor( } + /** Reload all UI state after a community config is applied to the shortcut. */ + private fun reloadAfterCommunityApply() { + loadInitialData() + loadResourceArrays() + loadContentsAsync() + shouldRefreshLibraryOnSave = true + } + + /** + * Preview "Apply": persist the user's (possibly edited) settings to the temp + * shortcut, then apply them to the REAL shortcut after a component check. + * If anything is missing, show the MISSING COMPONENT overlay and do nothing. + */ + private fun applyPreview() { + saveSettings() // writes edited UI state into the temp shortcut + val real = previewRealShortcut ?: return + val edited = com.winlator.cmod.feature.community.ConfigSerializer.serialize(shortcut) + Executors.newSingleThreadExecutor().execute { + val miss = com.winlator.cmod.feature.community.ComponentChecker + .findMissing(context, contentsManager, edited) + activity.runOnUiThread { + 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 @@ -2346,6 +2429,8 @@ class ShortcutSettingsComposeDialog private constructor( } fun dismiss() { + if (communityControllerLazy.isInitialized()) communityControllerLazy.value.dispose() + if (previewMode) runCatching { previewTempFile?.delete() } AppUtils.hideKeyboard(activity) dialog.dismiss() } @@ -2354,6 +2439,34 @@ class ShortcutSettingsComposeDialog private constructor( private const val TAG = "ShortcutSettingsCompose" private const val EXTRA_USE_CONTAINER_DEFAULTS = "use_container_defaults" + /** + * Open this dialog in PREVIEW mode for a community config: it shows the + * real Shortcut-Settings UI (same tabs/toggles/dropdowns) seeded from + * [communitySettings] on a throwaway temp shortcut, with a green Preview + * badge and an Apply button. The user may edit anything; Apply writes the + * result to [realShortcut] (after a MISSING-COMPONENT check). The stored + * community config and the real shortcut are untouched until Apply. + */ + @JvmStatic + fun preview( + activity: Activity, + realShortcut: Shortcut, + communitySettings: org.json.JSONObject, + onApplied: () -> Unit, + ) { + // Keep the real file name so the shown title is the game name (the + // Shortcut derives its name from the file name). cacheDir keeps it out + // of any container Desktop dir, so it never appears as a real shortcut. + val tempDir = File(activity.cacheDir, "wn_preview").apply { mkdirs() } + 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