From f2a346a54dbc5698e9b678ab8fb14a362bcee890 Mon Sep 17 00:00:00 2001 From: Phr3d13 Date: Tue, 4 Nov 2025 17:21:47 -0500 Subject: [PATCH 1/2] initial commit --- app/src/main/AndroidManifest.xml | 21 + .../main/java/app/gamenative/MainActivity.kt | 23 + .../app/gamenative/events/AndroidEvent.kt | 2 + .../component/dialog/ContainerConfigDialog.kt | 251 +++++++++ .../app/gamenative/utils/ContainerConfigIO.kt | 488 ++++++++++++++++++ 5 files changed, 785 insertions(+) create mode 100644 app/src/main/java/app/gamenative/utils/ContainerConfigIO.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ff716ac14..ae84f570e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,6 +49,27 @@ android:host="pluvia" android:scheme="home" /> + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index 859dd1dfd..cf1dd4d00 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -36,6 +36,7 @@ import app.gamenative.ui.PluviaMain import app.gamenative.ui.enums.Orientation import app.gamenative.utils.AnimatedPngDecoder import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.ContainerConfigIO import app.gamenative.utils.IconDecoder import app.gamenative.utils.IntentLaunchManager import com.posthog.PostHog @@ -187,6 +188,28 @@ class MainActivity : ComponentActivity() { private fun handleLaunchIntent(intent: Intent) { Timber.d("[IntentLaunch]: handleLaunchIntent called with action=${intent.action}") try { + // Handle deep link config import (gamenative://config?data=... or https://gamenative.app/config?data=...) + if (intent.action == Intent.ACTION_VIEW && intent.data != null) { + val uri = intent.data!! + val isConfigLink = (uri.scheme == "gamenative" && uri.host == "config") || + (uri.scheme == "https" && uri.host == "gamenative.app" && uri.path?.startsWith("/config") == true) + + if (isConfigLink) { + Timber.d("[ConfigImport]: Received config import link: ${uri.scheme}://${uri.host}${uri.path}") + val containerData = ContainerConfigIO.importFromDeepLink(uri) + if (containerData != null) { + Timber.i("[ConfigImport]: Successfully imported config from link: ${containerData.name}") + // Store imported config for user to apply + // You can emit an event here to show a dialog or navigate to container creation + PluviaApp.events.emit(AndroidEvent.ConfigImported(containerData)) + } else { + Timber.e("[ConfigImport]: Failed to parse config from link") + } + return + } + } + + // Handle game launch intent val launchRequest = IntentLaunchManager.parseLaunchIntent(intent) if (launchRequest != null) { Timber.d("[IntentLaunch]: Received external launch intent for app ${launchRequest.appId}") diff --git a/app/src/main/java/app/gamenative/events/AndroidEvent.kt b/app/src/main/java/app/gamenative/events/AndroidEvent.kt index 180ea632a..5584105d8 100644 --- a/app/src/main/java/app/gamenative/events/AndroidEvent.kt +++ b/app/src/main/java/app/gamenative/events/AndroidEvent.kt @@ -1,6 +1,7 @@ package app.gamenative.events import app.gamenative.ui.enums.Orientation +import com.winlator.container.ContainerData import java.util.EnumSet interface AndroidEvent : Event { @@ -18,5 +19,6 @@ interface AndroidEvent : Event { data class ShowGameFeedback(val appId: String) : AndroidEvent data class ShowLaunchingOverlay(val appName: String) : AndroidEvent data object HideLaunchingOverlay : AndroidEvent + data class ConfigImported(val containerData: ContainerData) : AndroidEvent // data class SetAppBarVisibility(val visible: Boolean) : AndroidEvent } diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index 92d8dca0f..0d650a456 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -1,10 +1,14 @@ package app.gamenative.ui.component.dialog +import android.content.Intent import android.widget.Toast import android.widget.Spinner import android.widget.ArrayAdapter import android.content.res.Configuration +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts 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 @@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -25,7 +30,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ViewList import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.AddCircleOutline import androidx.compose.material3.AlertDialog import androidx.compose.material3.CenterAlignedTopAppBar @@ -76,6 +84,7 @@ import app.gamenative.ui.theme.PluviaTheme import app.gamenative.ui.theme.settingsTileColors import app.gamenative.ui.theme.settingsTileColorsAlt import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.ContainerConfigIO import app.gamenative.service.SteamService import com.winlator.contents.ContentProfile import com.winlator.contents.ContentsManager @@ -133,6 +142,89 @@ fun ContainerConfigDialog( mutableStateOf(initialConfig) } + // Import/Export state + var showImportDialog by remember { mutableStateOf(false) } + var showExportDialog by remember { mutableStateOf(false) } + var showImportMenu by remember { mutableStateOf(false) } + var showShareCodeDialog by remember { mutableStateOf(false) } + var shareCodeInput by remember { mutableStateOf("") } + + // File picker for import + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + uri?.let { + val imported = ContainerConfigIO.importFromFile(context, it) + if (imported != null) { + config = imported + Toast.makeText(context, "Configuration imported successfully", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, "Failed to import configuration", Toast.LENGTH_LONG).show() + } + } + } + + // File picker for export + val exportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + uri?.let { + // Create a temporary container to export current config + val tempContainer = Container("temp").apply { + name = config.name + screenSize = config.screenSize + envVars = config.envVars + graphicsDriver = config.graphicsDriver + graphicsDriverVersion = config.graphicsDriverVersion + graphicsDriverConfig = config.graphicsDriverConfig + dxWrapper = config.dxwrapper + dxWrapperConfig = config.dxwrapperConfig + audioDriver = config.audioDriver + winComponents = config.wincomponents + drives = config.drives + execArgs = config.execArgs + executablePath = config.executablePath + installPath = config.installPath + isShowFPS = config.showFPS + isLaunchRealSteam = config.launchRealSteam + isAllowSteamUpdates = config.allowSteamUpdates + steamType = config.steamType + setCPUList(config.cpuList) + setCPUListWoW64(config.cpuListWoW64) + isWoW64Mode = config.wow64Mode + startupSelection = config.startupSelection + box86Version = config.box86Version + box64Version = config.box64Version + box86Preset = config.box86Preset + box64Preset = config.box64Preset + desktopTheme = config.desktopTheme + language = config.language + containerVariant = config.containerVariant + wineVersion = config.wineVersion + emulator = config.emulator + fexCoreVersion = config.fexcoreVersion + dinputMapperType = config.dinputMapperType + isSdlControllerAPI = config.sdlControllerAPI + isDisableMouseInput = config.disableMouseInput + isTouchscreenMode = config.touchscreenMode + isUseDRI3 = config.useDRI3 + isEmulateKeyboardMouse = config.emulateKeyboardMouse + isForceDlc = config.forceDlc + // Set controller emulation bindings if present + if (config.controllerEmulationBindings.isNotEmpty()) { + putExtra("controllerEmulationBindings", config.controllerEmulationBindings) + } + } + + val success = ContainerConfigIO.exportToFile(context, tempContainer, it) + if (success) { + Toast.makeText(context, "Configuration exported successfully", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, "Failed to export configuration", Toast.LENGTH_LONG).show() + } + } + } + val screenSizes = stringArrayResource(R.array.screen_size_entries).toList() val baseGraphicsDrivers = stringArrayResource(R.array.graphics_driver_entries).toList() var graphicsDrivers by remember { mutableStateOf(baseGraphicsDrivers.toMutableList()) } @@ -656,6 +748,64 @@ fun ContainerConfigDialog( }, ) } + + // Share Code Import Dialog + if (showShareCodeDialog) { + AlertDialog( + onDismissRequest = { + showShareCodeDialog = false + shareCodeInput = "" + }, + title = { Text("Import from Share Code") }, + text = { + Column { + Text("Paste the share code or link you received:") + Spacer(modifier = Modifier.padding(4.dp)) + Text( + text = "Accepts: GN1:..., gamenative://..., or https://gamenative.app/...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.padding(8.dp)) + OutlinedTextField( + value = shareCodeInput, + onValueChange = { shareCodeInput = it }, + label = { Text("Share Code or Link") }, + placeholder = { Text("GN1:... or https://...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = false, + maxLines = 6 + ) + } + }, + confirmButton = { + TextButton( + onClick = { + val imported = ContainerConfigIO.importFromShareCode(shareCodeInput) + if (imported != null) { + config = imported + Toast.makeText(context, "Configuration imported successfully", Toast.LENGTH_SHORT).show() + showShareCodeDialog = false + shareCodeInput = "" + } else { + Toast.makeText(context, "Invalid share code or link", Toast.LENGTH_LONG).show() + } + }, + enabled = shareCodeInput.isNotBlank() + ) { + Text("Import") + } + }, + dismissButton = { + TextButton(onClick = { + showShareCodeDialog = false + shareCodeInput = "" + }) { + Text("Cancel") + } + } + ) + } Dialog( onDismissRequest = onDismissCheck, @@ -684,6 +834,107 @@ fun ContainerConfigDialog( ) }, actions = { + // Import configuration button with dropdown menu + androidx.compose.foundation.layout.Box { + TextButton( + onClick = { showImportMenu = true }, + ) { + Icon( + imageVector = Icons.Default.FileOpen, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Import") + } + DropdownMenu( + expanded = showImportMenu, + onDismissRequest = { showImportMenu = false } + ) { + DropdownMenuItem( + text = { Text("From File") }, + onClick = { + showImportMenu = false + importLauncher.launch("application/json") + } + ) + DropdownMenuItem( + text = { Text("From Share Code") }, + onClick = { + showImportMenu = false + showShareCodeDialog = true + } + ) + } + } + // Export configuration button + TextButton( + onClick = { + val filename = ContainerConfigIO.generateExportFilename(title.ifEmpty { "config" }) + exportLauncher.launch(filename) + }, + ) { + Icon( + imageVector = Icons.Default.FileDownload, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Export") + } + // Share configuration button + IconButton( + onClick = { + // Create a temporary container to share current config + val tempContainer = Container("temp").apply { + name = config.name + screenSize = config.screenSize + envVars = config.envVars + graphicsDriver = config.graphicsDriver + graphicsDriverVersion = config.graphicsDriverVersion + graphicsDriverConfig = config.graphicsDriverConfig + dxWrapper = config.dxwrapper + dxWrapperConfig = config.dxwrapperConfig + audioDriver = config.audioDriver + winComponents = config.wincomponents + drives = config.drives + execArgs = config.execArgs + isShowFPS = config.showFPS + isLaunchRealSteam = config.launchRealSteam + isAllowSteamUpdates = config.allowSteamUpdates + steamType = config.steamType + setCPUList(config.cpuList) + setCPUListWoW64(config.cpuListWoW64) + isWoW64Mode = config.wow64Mode + startupSelection = config.startupSelection + box86Version = config.box86Version + box64Version = config.box64Version + box86Preset = config.box86Preset + box64Preset = config.box64Preset + desktopTheme = config.desktopTheme + containerVariant = config.containerVariant + wineVersion = config.wineVersion + emulator = config.emulator + fexCoreVersion = config.fexcoreVersion + dinputMapperType = config.dinputMapperType + isSdlControllerAPI = config.sdlControllerAPI + isDisableMouseInput = config.disableMouseInput + isTouchscreenMode = config.touchscreenMode + isUseDRI3 = config.useDRI3 + isEmulateKeyboardMouse = config.emulateKeyboardMouse + isForceDlc = config.forceDlc + language = config.language + } + val shareIntent = ContainerConfigIO.createShareMessageIntent(tempContainer, title) + context.startActivity(Intent.createChooser(shareIntent, "Share Config")) + }, + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share Config" + ) + } + // Save button IconButton( onClick = { onSave(config) }, content = { Icon(Icons.Default.Save, null) }, diff --git a/app/src/main/java/app/gamenative/utils/ContainerConfigIO.kt b/app/src/main/java/app/gamenative/utils/ContainerConfigIO.kt new file mode 100644 index 000000000..bd0f41922 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/ContainerConfigIO.kt @@ -0,0 +1,488 @@ +package app.gamenative.utils + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Base64 +import androidx.documentfile.provider.DocumentFile +import com.winlator.container.Container +import com.winlator.container.ContainerData +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.util.zip.GZIPOutputStream +import java.util.zip.GZIPInputStream +import java.io.ByteArrayOutputStream +import java.io.ByteArrayInputStream + +/** + * Utilities for importing and exporting container configurations. + * Supports both file-based import/export and Intent-based sharing. + */ +object ContainerConfigIO { + + private const val CONTAINER_CONFIG_VERSION = 1 + private const val MIME_TYPE_JSON = "application/json" + + /** + * Exports a container's configuration to a JSON file. + * + * @param context Android context + * @param container The container to export + * @param targetUri Document URI where to save the config (from SAF) + * @return true if export succeeded, false otherwise + */ + fun exportToFile(context: Context, container: Container, targetUri: Uri): Boolean { + return try { + val configJson = exportToJson(container) + + context.contentResolver.openOutputStream(targetUri)?.use { outputStream -> + outputStream.write(configJson.toByteArray()) + outputStream.flush() + } + + Timber.i("[ContainerConfigIO]: Successfully exported config for container '${container.name}' to $targetUri") + true + } catch (e: Exception) { + Timber.e(e, "[ContainerConfigIO]: Failed to export config to file") + false + } + } + + /** + * Exports a container's configuration to a JSON string. + * + * @param container The container to export + * @return JSON string representation of the container config + */ + fun exportToJson(container: Container): String { + val configData = JSONObject().apply { + put("version", CONTAINER_CONFIG_VERSION) + put("exportedFrom", "GameNative") + put("containerName", container.name) + put("timestamp", System.currentTimeMillis()) + + // Core container settings + val config = JSONObject().apply { + put("screenSize", container.screenSize) + put("envVars", container.envVars) + put("graphicsDriver", container.graphicsDriver) + put("graphicsDriverVersion", container.graphicsDriverVersion) + put("graphicsDriverConfig", container.graphicsDriverConfig) + put("dxwrapper", container.dxWrapper) + put("dxwrapperConfig", container.dxWrapperConfig) + put("audioDriver", container.audioDriver) + put("wincomponents", container.winComponents) + put("drives", container.drives) + put("execArgs", container.execArgs) + put("showFPS", container.isShowFPS) + put("launchRealSteam", container.isLaunchRealSteam) + put("allowSteamUpdates", container.isAllowSteamUpdates) + put("steamType", container.steamType) + put("cpuList", container.getCPUList(false)) + put("cpuListWoW64", container.getCPUListWoW64(false)) + put("wow64Mode", container.isWoW64Mode) + put("startupSelection", container.startupSelection.toInt()) + put("box86Version", container.box86Version) + put("box64Version", container.box64Version) + put("box86Preset", container.box86Preset) + put("box64Preset", container.box64Preset) + put("desktopTheme", container.desktopTheme) + put("language", container.language) + put("containerVariant", container.containerVariant) + put("wineVersion", container.wineVersion) + put("emulator", container.emulator) + put("fexcoreVersion", container.fexCoreVersion) + put("inputType", container.inputType) + put("dinputMapperType", container.dinputMapperType.toInt()) + put("sdlControllerAPI", container.isSdlControllerAPI) + put("disableMouseInput", container.isDisableMouseInput) + put("touchscreenMode", container.isTouchscreenMode) + put("useDRI3", container.isUseDRI3) + put("emulateKeyboardMouse", container.isEmulateKeyboardMouse) + put("forceDlc", container.isForceDlc) + + // Additional Container-specific fields not in ContainerData + put("primaryController", container.primaryController) + put("lc_all", container.getExtra("lc_all", "en_US.utf8")) + put("inputType", container.inputType) + + // Include MIDI sound font if set + if (container.midiSoundFont.isNotEmpty()) { + put("midiSoundFont", container.midiSoundFont) + } + + // Include controller mapping if set + val controllerMapping = container.getExtra("controllerMapping", "") + if (controllerMapping.isNotEmpty()) { + put("controllerMapping", controllerMapping) + } + + // Include controller emulation bindings if set + val emulationBindings = container.getExtra("controllerEmulationBindings") + if (emulationBindings.isNotEmpty()) { + put("controllerEmulationBindings", JSONObject(emulationBindings)) + } + } + + put("config", config) + } + + return configData.toString(2) // Pretty print with 2-space indent + } + + /** + * Imports a container configuration from a JSON file. + * + * @param context Android context + * @param sourceUri Document URI to read from (from SAF) + * @return ContainerData object if import succeeded, null otherwise + */ + fun importFromFile(context: Context, sourceUri: Uri): ContainerData? { + return try { + val jsonString = context.contentResolver.openInputStream(sourceUri)?.use { inputStream -> + inputStream.bufferedReader().use { it.readText() } + } ?: return null + + importFromJson(jsonString) + } catch (e: Exception) { + Timber.e(e, "[ContainerConfigIO]: Failed to import config from file") + null + } + } + + /** + * Imports a container configuration from a JSON string. + * + * @param jsonString JSON string containing the container config + * @return ContainerData object if import succeeded, null otherwise + */ + fun importFromJson(jsonString: String): ContainerData? { + return try { + val rootJson = JSONObject(jsonString) + + // Validate version + val version = rootJson.optInt("version", 0) + if (version > CONTAINER_CONFIG_VERSION) { + Timber.w("[ContainerConfigIO]: Config version $version is newer than supported version $CONTAINER_CONFIG_VERSION") + // Continue anyway, may still be compatible + } + + val config = rootJson.getJSONObject("config") + + // Parse into ContainerData + ContainerData( + name = rootJson.optString("containerName", "Imported Config"), + screenSize = config.optString("screenSize", Container.DEFAULT_SCREEN_SIZE), + envVars = config.optString("envVars", Container.DEFAULT_ENV_VARS), + graphicsDriver = config.optString("graphicsDriver", Container.DEFAULT_GRAPHICS_DRIVER), + graphicsDriverVersion = config.optString("graphicsDriverVersion", ""), + graphicsDriverConfig = config.optString("graphicsDriverConfig", ""), + dxwrapper = config.optString("dxwrapper", Container.DEFAULT_DXWRAPPER), + dxwrapperConfig = config.optString("dxwrapperConfig", ""), + audioDriver = config.optString("audioDriver", Container.DEFAULT_AUDIO_DRIVER), + wincomponents = config.optString("wincomponents", Container.DEFAULT_WINCOMPONENTS), + drives = config.optString("drives", Container.DEFAULT_DRIVES), + execArgs = config.optString("execArgs", ""), + showFPS = config.optBoolean("showFPS", false), + launchRealSteam = config.optBoolean("launchRealSteam", false), + allowSteamUpdates = config.optBoolean("allowSteamUpdates", false), + steamType = config.optString("steamType", "normal"), + cpuList = config.optString("cpuList", Container.getFallbackCPUList()), + cpuListWoW64 = config.optString("cpuListWoW64", Container.getFallbackCPUListWoW64()), + wow64Mode = config.optBoolean("wow64Mode", true), + startupSelection = config.optInt("startupSelection", Container.STARTUP_SELECTION_ESSENTIAL.toInt()).toByte(), + box86Version = config.optString("box86Version", ""), + box64Version = config.optString("box64Version", ""), + box86Preset = config.optString("box86Preset", ""), + box64Preset = config.optString("box64Preset", ""), + desktopTheme = config.optString("desktopTheme", ""), + containerVariant = config.optString("containerVariant", Container.DEFAULT_VARIANT), + wineVersion = config.optString("wineVersion", Container.DEFAULT_WINE_VERSION), + emulator = config.optString("emulator", Container.DEFAULT_EMULATOR), + fexcoreVersion = config.optString("fexcoreVersion", ""), + dinputMapperType = config.optInt("dinputMapperType", 1).toByte(), + sdlControllerAPI = config.optBoolean("sdlControllerAPI", true), + disableMouseInput = config.optBoolean("disableMouseInput", false), + touchscreenMode = config.optBoolean("touchscreenMode", false), + useDRI3 = config.optBoolean("useDRI3", false), + emulateKeyboardMouse = config.optBoolean("emulateKeyboardMouse", false), + forceDlc = config.optBoolean("forceDlc", false), + language = config.optString("language", "english"), + // FEXCore settings + fexcoreTSOMode = config.optString("fexcoreTSOMode", "Fast"), + fexcoreX87Mode = config.optString("fexcoreX87Mode", "Fast"), + fexcoreMultiBlock = config.optString("fexcoreMultiBlock", "Disabled"), + // Wine registry settings + renderer = config.optString("renderer", "gl"), + csmt = config.optBoolean("csmt", true), + videoPciDeviceID = config.optInt("videoPciDeviceID", 1728), + offScreenRenderingMode = config.optString("offScreenRenderingMode", "fbo"), + strictShaderMath = config.optBoolean("strictShaderMath", true), + videoMemorySize = config.optString("videoMemorySize", "2048"), + mouseWarpOverride = config.optString("mouseWarpOverride", "disable"), + shaderBackend = config.optString("shaderBackend", "glsl"), + useGLSL = config.optString("useGLSL", "enabled"), + enableXInput = config.optBoolean("enableXInput", true), + enableDInput = config.optBoolean("enableDInput", true), + executablePath = config.optString("executablePath", ""), + installPath = config.optString("installPath", ""), + controllerEmulationBindings = config.optString("controllerEmulationBindings", ""), + ).also { + Timber.i("[ContainerConfigIO]: Successfully imported config '${it.name}'") + } + } catch (e: Exception) { + Timber.e(e, "[ContainerConfigIO]: Failed to parse container config JSON") + null + } + } + + /** + * Creates an Android Intent to share a container configuration. + * This allows the config to be shared via Intent to other apps or the IntentLaunchManager. + * + * @param container The container to share + * @return Intent configured for sharing the container config + */ + fun createShareIntent(container: Container): Intent { + val configJson = exportToJson(container) + + return Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, configJson) + putExtra(Intent.EXTRA_SUBJECT, "GameNative Container Config: ${container.name}") + } + } + + /** + * Creates a shareable message with a deep link to import the container config. + * This allows users to share configs via messaging apps. + * Includes both web link (clickable in Discord) and share code (for copy-paste). + * + * @param container The container to share + * @param gameName Optional game name to include in the message + * @return Intent configured for sharing via Android's share sheet + */ + fun createShareMessageIntent(container: Container, gameName: String = ""): Intent { + val webLink = createWebLink(container) + val configCode = createShareCode(container) + val displayName = gameName.ifEmpty { container.name } + + val message = buildString { + appendLine("🎮 GameNative Config: $displayName") + appendLine() + appendLine("Settings: ${container.graphicsDriver}, ${container.dxWrapper}, Wine ${container.wineVersion}") + appendLine() + appendLine("� Click to import:") + appendLine(webLink) + appendLine() + appendLine("📋 Or paste this code in GameNative → Import → From Share Code:") + appendLine(configCode) + } + + return Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, message) + putExtra(Intent.EXTRA_SUBJECT, "GameNative Config: $displayName") + } + } + + /** + * Creates a compact share code for the container config. + * This is a base64-encoded, gzipped JSON that can be copied and pasted. + * + * @param container The container to encode + * @return Share code string (base64) + */ + fun createShareCode(container: Container): String { + val configJson = exportToJson(container) + + // Compress JSON to reduce size + val compressed = ByteArrayOutputStream().use { byteStream -> + GZIPOutputStream(byteStream).use { gzipStream -> + gzipStream.write(configJson.toByteArray()) + } + byteStream.toByteArray() + } + + // Base64 encode for easy copy-paste + val encoded = Base64.encodeToString(compressed, Base64.NO_WRAP) + + return "GN1:$encoded" // Prefix with version identifier + } + + /** + * Imports a container configuration from a share code. + * Supports multiple formats: + * - GN1:... (base64 share code) + * - gamenative://config?data=... (deep link) + * - https://gamenative.app/config?data=... (web link) + * + * @param shareCode The share code string or URL + * @return ContainerData if successful, null otherwise + */ + fun importFromShareCode(shareCode: String): ContainerData? { + return try { + val trimmed = shareCode.trim() + + // Check if it's a URL (gamenative:// or https://) + if (trimmed.startsWith("gamenative://") || trimmed.startsWith("https://")) { + val uri = Uri.parse(trimmed) + return importFromDeepLink(uri) + } + + // Otherwise treat as base64 share code + val code = if (trimmed.startsWith("GN1:")) { + trimmed.substring(4) + } else { + trimmed + } + + // Base64 decode + val compressed = Base64.decode(code, Base64.NO_WRAP) + + // Decompress + val jsonString = ByteArrayInputStream(compressed).use { byteStream -> + GZIPInputStream(byteStream).use { gzipStream -> + gzipStream.bufferedReader().use { it.readText() } + } + } + + importFromJson(jsonString) + } catch (e: Exception) { + Timber.e(e, "[ContainerConfigIO]: Failed to import from share code") + null + } + } + + /** + * Creates a web link that redirects to the deep link. + * This makes the link clickable in messaging apps like Discord. + * Format: https://gamenative.app/config?data= + * + * @param container The container to encode + * @return Web link URL string + */ + fun createWebLink(container: Container): String { + val configJson = exportToJson(container) + + // Compress JSON to reduce link size + val compressed = ByteArrayOutputStream().use { byteStream -> + GZIPOutputStream(byteStream).use { gzipStream -> + gzipStream.write(configJson.toByteArray()) + } + byteStream.toByteArray() + } + + // Base64 encode for URL safety + val encoded = Base64.encodeToString(compressed, Base64.URL_SAFE or Base64.NO_WRAP) + + return "https://gamenative.app/config?data=$encoded" + } + + /** + * Creates a deep link URL for importing a container config. + * Format: gamenative://config?data= + * + * @param container The container to encode + * @return Deep link URL string + */ + fun createDeepLink(container: Container): String { + val configJson = exportToJson(container) + + // Compress JSON to reduce link size + val compressed = ByteArrayOutputStream().use { byteStream -> + GZIPOutputStream(byteStream).use { gzipStream -> + gzipStream.write(configJson.toByteArray()) + } + byteStream.toByteArray() + } + + // Base64 encode for URL safety + val encoded = Base64.encodeToString(compressed, Base64.URL_SAFE or Base64.NO_WRAP) + + return "gamenative://config?data=$encoded" + } + + /** + * Parses a deep link URL and extracts the container configuration. + * + * @param deepLinkUri The deep link URI to parse + * @return ContainerData if successful, null otherwise + */ + fun importFromDeepLink(deepLinkUri: Uri): ContainerData? { + return try { + val encodedData = deepLinkUri.getQueryParameter("data") ?: return null + + // Base64 decode + val compressed = Base64.decode(encodedData, Base64.URL_SAFE or Base64.NO_WRAP) + + // Decompress + val jsonString = ByteArrayInputStream(compressed).use { byteStream -> + GZIPInputStream(byteStream).use { gzipStream -> + gzipStream.bufferedReader().use { it.readText() } + } + } + + importFromJson(jsonString) + } catch (e: Exception) { + Timber.e(e, "[ContainerConfigIO]: Failed to import from deep link") + null + } + } + + /** + * Creates an Intent that can be used to launch a game with a specific container configuration. + * This integrates with the IntentLaunchManager system. + * + * @param context Android context + * @param gameId The Steam game ID + * @param containerConfig The container configuration to use + * @return Intent configured for launching the game with the config + */ + fun createLaunchIntent(context: Context, gameId: Int, containerConfig: ContainerData): Intent { + // Convert ContainerData to JSON for the intent + val configJson = JSONObject().apply { + put("screenSize", containerConfig.screenSize) + put("envVars", containerConfig.envVars) + put("graphicsDriver", containerConfig.graphicsDriver) + put("graphicsDriverVersion", containerConfig.graphicsDriverVersion) + put("dxwrapper", containerConfig.dxwrapper) + put("dxwrapperConfig", containerConfig.dxwrapperConfig) + put("audioDriver", containerConfig.audioDriver) + put("wincomponents", containerConfig.wincomponents) + put("drives", containerConfig.drives) + put("execArgs", containerConfig.execArgs) + put("showFPS", containerConfig.showFPS) + put("launchRealSteam", containerConfig.launchRealSteam) + put("cpuList", containerConfig.cpuList) + put("cpuListWoW64", containerConfig.cpuListWoW64) + put("wow64Mode", containerConfig.wow64Mode) + put("startupSelection", containerConfig.startupSelection.toInt()) + put("box86Version", containerConfig.box86Version) + put("box64Version", containerConfig.box64Version) + put("box86Preset", containerConfig.box86Preset) + put("box64Preset", containerConfig.box64Preset) + }.toString() + + return Intent("app.gamenative.LAUNCH_GAME").apply { + setPackage(context.packageName) + putExtra("app_id", gameId) + putExtra("container_config", configJson) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + } + + /** + * Generates a filename for exporting a container config. + * + * @param name The name to use (e.g., game name or container name) + * @return Suggested filename with readable timestamp + */ + fun generateExportFilename(name: String): String { + val sanitized = name.replace(Regex("[^a-zA-Z0-9_-]"), "_") + val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", java.util.Locale.US) + val timestamp = dateFormat.format(java.util.Date()) + return "${sanitized}_${timestamp}.json" + } +} From dc5ae6a1d5f4f312cd836b930a87568949bccde1 Mon Sep 17 00:00:00 2001 From: Phr3d13 Date: Thu, 13 Nov 2025 13:29:00 -0500 Subject: [PATCH 2/2] address issues / suggestion --- app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 4 +- .../component/dialog/ContainerConfigDialog.kt | 93 +---- .../app/gamenative/utils/ContainerConfigIO.kt | 387 +++++++++++++++++- app/src/main/res/values/strings.xml | 4 + 5 files changed, 391 insertions(+), 99 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b1c64c4a3..80cf7239e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,7 +51,7 @@ android { minSdk = 26 targetSdk = 28 - versionCode = 4 + versionCode = 5 versionName = "0.5.0" buildConfigField("boolean", "GOLD", "false") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ae84f570e..c01d72e42 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -59,7 +59,9 @@ android:host="config" android:scheme="gamenative" /> - + + diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index 0d650a456..739d8a254 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -85,6 +85,7 @@ import app.gamenative.ui.theme.settingsTileColors import app.gamenative.ui.theme.settingsTileColorsAlt import app.gamenative.utils.ContainerUtils import app.gamenative.utils.ContainerConfigIO +import app.gamenative.utils.ContainerConfigIO.toTempContainer import app.gamenative.service.SteamService import com.winlator.contents.ContentProfile import com.winlator.contents.ContentsManager @@ -159,7 +160,7 @@ fun ContainerConfigDialog( config = imported Toast.makeText(context, "Configuration imported successfully", Toast.LENGTH_SHORT).show() } else { - Toast.makeText(context, "Failed to import configuration", Toast.LENGTH_LONG).show() + Toast.makeText(context, context.getString(R.string.failed_to_import_config), Toast.LENGTH_LONG).show() } } } @@ -170,51 +171,7 @@ fun ContainerConfigDialog( ) { uri -> uri?.let { // Create a temporary container to export current config - val tempContainer = Container("temp").apply { - name = config.name - screenSize = config.screenSize - envVars = config.envVars - graphicsDriver = config.graphicsDriver - graphicsDriverVersion = config.graphicsDriverVersion - graphicsDriverConfig = config.graphicsDriverConfig - dxWrapper = config.dxwrapper - dxWrapperConfig = config.dxwrapperConfig - audioDriver = config.audioDriver - winComponents = config.wincomponents - drives = config.drives - execArgs = config.execArgs - executablePath = config.executablePath - installPath = config.installPath - isShowFPS = config.showFPS - isLaunchRealSteam = config.launchRealSteam - isAllowSteamUpdates = config.allowSteamUpdates - steamType = config.steamType - setCPUList(config.cpuList) - setCPUListWoW64(config.cpuListWoW64) - isWoW64Mode = config.wow64Mode - startupSelection = config.startupSelection - box86Version = config.box86Version - box64Version = config.box64Version - box86Preset = config.box86Preset - box64Preset = config.box64Preset - desktopTheme = config.desktopTheme - language = config.language - containerVariant = config.containerVariant - wineVersion = config.wineVersion - emulator = config.emulator - fexCoreVersion = config.fexcoreVersion - dinputMapperType = config.dinputMapperType - isSdlControllerAPI = config.sdlControllerAPI - isDisableMouseInput = config.disableMouseInput - isTouchscreenMode = config.touchscreenMode - isUseDRI3 = config.useDRI3 - isEmulateKeyboardMouse = config.emulateKeyboardMouse - isForceDlc = config.forceDlc - // Set controller emulation bindings if present - if (config.controllerEmulationBindings.isNotEmpty()) { - putExtra("controllerEmulationBindings", config.controllerEmulationBindings) - } - } + val tempContainer = config.toTempContainer() val success = ContainerConfigIO.exportToFile(context, tempContainer, it) if (success) { @@ -886,52 +843,14 @@ fun ContainerConfigDialog( IconButton( onClick = { // Create a temporary container to share current config - val tempContainer = Container("temp").apply { - name = config.name - screenSize = config.screenSize - envVars = config.envVars - graphicsDriver = config.graphicsDriver - graphicsDriverVersion = config.graphicsDriverVersion - graphicsDriverConfig = config.graphicsDriverConfig - dxWrapper = config.dxwrapper - dxWrapperConfig = config.dxwrapperConfig - audioDriver = config.audioDriver - winComponents = config.wincomponents - drives = config.drives - execArgs = config.execArgs - isShowFPS = config.showFPS - isLaunchRealSteam = config.launchRealSteam - isAllowSteamUpdates = config.allowSteamUpdates - steamType = config.steamType - setCPUList(config.cpuList) - setCPUListWoW64(config.cpuListWoW64) - isWoW64Mode = config.wow64Mode - startupSelection = config.startupSelection - box86Version = config.box86Version - box64Version = config.box64Version - box86Preset = config.box86Preset - box64Preset = config.box64Preset - desktopTheme = config.desktopTheme - containerVariant = config.containerVariant - wineVersion = config.wineVersion - emulator = config.emulator - fexCoreVersion = config.fexcoreVersion - dinputMapperType = config.dinputMapperType - isSdlControllerAPI = config.sdlControllerAPI - isDisableMouseInput = config.disableMouseInput - isTouchscreenMode = config.touchscreenMode - isUseDRI3 = config.useDRI3 - isEmulateKeyboardMouse = config.emulateKeyboardMouse - isForceDlc = config.forceDlc - language = config.language - } + val tempContainer = config.toTempContainer() val shareIntent = ContainerConfigIO.createShareMessageIntent(tempContainer, title) - context.startActivity(Intent.createChooser(shareIntent, "Share Config")) + context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.share_config))) }, ) { Icon( imageVector = Icons.Default.Share, - contentDescription = "Share Config" + contentDescription = stringResource(R.string.share_config) ) } // Save button diff --git a/app/src/main/java/app/gamenative/utils/ContainerConfigIO.kt b/app/src/main/java/app/gamenative/utils/ContainerConfigIO.kt index bd0f41922..ed4b3ae19 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerConfigIO.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerConfigIO.kt @@ -24,6 +24,232 @@ object ContainerConfigIO { private const val CONTAINER_CONFIG_VERSION = 1 private const val MIME_TYPE_JSON = "application/json" + // Security limits + private const val MAX_JSON_SIZE_BYTES = 1_048_576 // 1MB max + private const val MAX_STRING_LENGTH = 10_000 + private const val MAX_EXEC_ARGS_LENGTH = 2000 + private const val MAX_ENV_VARS_LENGTH = 5000 + + /** + * Converts ContainerData to a temporary Container for export/share operations. + * This helper ensures consistent container creation across export and share flows. + */ + internal fun ContainerData.toTempContainer(): Container { + return Container("temp").apply { + name = this@toTempContainer.name + screenSize = this@toTempContainer.screenSize + envVars = this@toTempContainer.envVars + graphicsDriver = this@toTempContainer.graphicsDriver + graphicsDriverVersion = this@toTempContainer.graphicsDriverVersion + graphicsDriverConfig = this@toTempContainer.graphicsDriverConfig + dxWrapper = this@toTempContainer.dxwrapper + dxWrapperConfig = this@toTempContainer.dxwrapperConfig + audioDriver = this@toTempContainer.audioDriver + winComponents = this@toTempContainer.wincomponents + drives = this@toTempContainer.drives + execArgs = this@toTempContainer.execArgs + executablePath = this@toTempContainer.executablePath + installPath = this@toTempContainer.installPath + isShowFPS = this@toTempContainer.showFPS + isLaunchRealSteam = this@toTempContainer.launchRealSteam + isAllowSteamUpdates = this@toTempContainer.allowSteamUpdates + steamType = this@toTempContainer.steamType + setCPUList(this@toTempContainer.cpuList) + setCPUListWoW64(this@toTempContainer.cpuListWoW64) + isWoW64Mode = this@toTempContainer.wow64Mode + startupSelection = this@toTempContainer.startupSelection + box86Version = this@toTempContainer.box86Version + box64Version = this@toTempContainer.box64Version + box86Preset = this@toTempContainer.box86Preset + box64Preset = this@toTempContainer.box64Preset + desktopTheme = this@toTempContainer.desktopTheme + language = this@toTempContainer.language + containerVariant = this@toTempContainer.containerVariant + wineVersion = this@toTempContainer.wineVersion + emulator = this@toTempContainer.emulator + fexCoreVersion = this@toTempContainer.fexcoreVersion + dinputMapperType = this@toTempContainer.dinputMapperType + isSdlControllerAPI = this@toTempContainer.sdlControllerAPI + isDisableMouseInput = this@toTempContainer.disableMouseInput + isTouchscreenMode = this@toTempContainer.touchscreenMode + isUseDRI3 = this@toTempContainer.useDRI3 + isEmulateKeyboardMouse = this@toTempContainer.emulateKeyboardMouse + isForceDlc = this@toTempContainer.forceDlc + // Set controller emulation bindings if present + if (this@toTempContainer.controllerEmulationBindings.isNotEmpty()) { + putExtra("controllerEmulationBindings", this@toTempContainer.controllerEmulationBindings) + } + // FEXCore settings + putExtra("fexcoreTSOMode", this@toTempContainer.fexcoreTSOMode) + putExtra("fexcoreX87Mode", this@toTempContainer.fexcoreX87Mode) + putExtra("fexcoreMultiBlock", this@toTempContainer.fexcoreMultiBlock) + // Wine registry settings + putExtra("renderer", this@toTempContainer.renderer) + putExtra("csmt", this@toTempContainer.csmt.toString()) + putExtra("videoPciDeviceID", this@toTempContainer.videoPciDeviceID.toString()) + putExtra("offScreenRenderingMode", this@toTempContainer.offScreenRenderingMode) + putExtra("strictShaderMath", this@toTempContainer.strictShaderMath.toString()) + putExtra("videoMemorySize", this@toTempContainer.videoMemorySize) + putExtra("mouseWarpOverride", this@toTempContainer.mouseWarpOverride) + putExtra("shaderBackend", this@toTempContainer.shaderBackend) + putExtra("useGLSL", this@toTempContainer.useGLSL) + putExtra("enableXInput", this@toTempContainer.enableXInput.toString()) + putExtra("enableDInput", this@toTempContainer.enableDInput.toString()) + } + } + + /** + * Sanitizes imported configuration data to prevent malicious inputs. + * Validates paths, limits string lengths, and removes dangerous characters. + */ + private fun sanitizeContainerData(data: ContainerData): ContainerData { + return data.copy( + // Sanitize string lengths + name = data.name.take(100).sanitizeDisplayString(), + screenSize = data.screenSize.take(50), + envVars = data.envVars.take(MAX_ENV_VARS_LENGTH).sanitizeEnvVars(), + graphicsDriver = data.graphicsDriver.take(100), + graphicsDriverVersion = data.graphicsDriverVersion.take(100), + graphicsDriverConfig = data.graphicsDriverConfig.take(500), + dxwrapper = data.dxwrapper.take(100), + dxwrapperConfig = data.dxwrapperConfig.take(500), + audioDriver = data.audioDriver.take(100), + wincomponents = data.wincomponents.take(500), + + // Sanitize potentially dangerous fields + drives = data.drives.take(1000).sanitizeDrives(), + execArgs = data.execArgs.take(MAX_EXEC_ARGS_LENGTH).sanitizeExecArgs(), + executablePath = data.executablePath.take(500).sanitizePath(), + installPath = data.installPath.take(500).sanitizePath(), + + // Sanitize other string fields + steamType = data.steamType.take(50), + cpuList = data.cpuList.take(200), + cpuListWoW64 = data.cpuListWoW64.take(200), + box86Version = data.box86Version.take(100), + box64Version = data.box64Version.take(100), + box86Preset = data.box86Preset.take(100), + box64Preset = data.box64Preset.take(100), + desktopTheme = data.desktopTheme.take(100), + containerVariant = data.containerVariant.take(100), + wineVersion = data.wineVersion.take(100), + emulator = data.emulator.take(100), + fexcoreVersion = data.fexcoreVersion.take(100), + language = data.language.take(50), + + // Sanitize FEXCore settings + fexcoreTSOMode = data.fexcoreTSOMode.take(50), + fexcoreX87Mode = data.fexcoreX87Mode.take(50), + fexcoreMultiBlock = data.fexcoreMultiBlock.take(50), + + // Sanitize Wine registry settings + renderer = data.renderer.take(50), + offScreenRenderingMode = data.offScreenRenderingMode.take(50), + videoMemorySize = data.videoMemorySize.take(50), + mouseWarpOverride = data.mouseWarpOverride.take(50), + shaderBackend = data.shaderBackend.take(50), + useGLSL = data.useGLSL.take(50), + controllerEmulationBindings = data.controllerEmulationBindings.take(MAX_STRING_LENGTH), + ) + } + + /** + * Sanitizes display strings by removing control characters and nulls. + */ + private fun String.sanitizeDisplayString(): String { + return this.replace(Regex("[\u0000-\u001F\u007F]"), "").trim() + } + + /** + * Sanitizes environment variables to prevent injection. + * Removes dangerous characters and validates format. + */ + private fun String.sanitizeEnvVars(): String { + // Split by semicolon, validate each env var + return this.split(";") + .filter { it.isNotBlank() } + .map { envVar -> + val trimmed = envVar.trim() + // Only allow alphanumeric, underscore, equals, dash, dot, slash, colon + // This prevents shell metacharacters and command substitution + trimmed.replace(Regex("[^a-zA-Z0-9_=\\-./:]"), "") + } + .filter { it.contains("=") } // Must have key=value format + .take(50) // Limit number of env vars + .joinToString(";") + } + + /** + * Sanitizes executable arguments to prevent command injection. + * Removes shell metacharacters and dangerous patterns. + */ + private fun String.sanitizeExecArgs(): String { + // Remove potentially dangerous shell metacharacters + // Allow: alphanumeric, space, dash, equals, dot, slash, colon, underscore, comma + // Disallow: pipes, redirects, command substitution, etc. + return this.replace(Regex("[;|&$`<>(){}\\[\\]!*?~#]"), "") + .replace(Regex("\\s+"), " ") // Normalize whitespace + .trim() + } + + /** + * Sanitizes file paths to prevent path traversal. + * Removes potentially dangerous path components. + */ + private fun String.sanitizePath(): String { + if (this.isBlank()) return "" + + // Remove null bytes + var sanitized = this.replace("\u0000", "") + + // Remove dangerous path traversal patterns + sanitized = sanitized.replace("..", "") + + // For Windows paths (C:, Z:, etc.), preserve the drive letter + // But validate it's a reasonable path + if (sanitized.matches(Regex("^[A-Za-z]:[/\\\\].*"))) { + // Windows path - allow drive letter and forward/backslashes + sanitized = sanitized.replace(Regex("[^a-zA-Z0-9_\\-./:\\\\() ]"), "") + } else if (sanitized.startsWith("/")) { + // Unix path + sanitized = sanitized.replace(Regex("[^a-zA-Z0-9_\\-./() ]"), "") + } else { + // Relative path or invalid + sanitized = sanitized.replace(Regex("[^a-zA-Z0-9_\\-./() ]"), "") + } + + return sanitized.trim() + } + + /** + * Sanitizes drive mappings to prevent arbitrary filesystem access. + * Only allows standard drive letter mappings. + */ + private fun String.sanitizeDrives(): String { + // Drive format is like: "Z:/home/user/.local/share/gamenative/drive" + // Allow multiple drives separated by spaces + return this.split(Regex("\\s+")) + .filter { it.isNotBlank() } + .map { drive -> + // Must match pattern: LETTER:PATH + if (drive.matches(Regex("^[A-Za-z]:[/\\\\].*"))) { + // Sanitize the path part + val parts = drive.split(":", limit = 2) + if (parts.size == 2) { + val letter = parts[0].uppercase() + val path = parts[1].sanitizePath() + "$letter:$path" + } else { + "" + } + } else { + "" + } + } + .filter { it.isNotEmpty() } + .joinToString(" ") + } + /** * Exports a container's configuration to a JSON file. * @@ -59,6 +285,7 @@ object ContainerConfigIO { val configData = JSONObject().apply { put("version", CONTAINER_CONFIG_VERSION) put("exportedFrom", "GameNative") + put("appVersion", app.gamenative.BuildConfig.VERSION_NAME) put("containerName", container.name) put("timestamp", System.currentTimeMillis()) @@ -105,7 +332,28 @@ object ContainerConfigIO { // Additional Container-specific fields not in ContainerData put("primaryController", container.primaryController) put("lc_all", container.getExtra("lc_all", "en_US.utf8")) - put("inputType", container.inputType) + + // FEXCore settings + put("fexcoreTSOMode", container.getExtra("fexcoreTSOMode", "Fast")) + put("fexcoreX87Mode", container.getExtra("fexcoreX87Mode", "Fast")) + put("fexcoreMultiBlock", container.getExtra("fexcoreMultiBlock", "Disabled")) + + // Wine registry settings + put("renderer", container.getExtra("renderer", "gl")) + put("csmt", container.getExtra("csmt", "true").toBoolean()) + put("videoPciDeviceID", container.getExtra("videoPciDeviceID", "1728").toIntOrNull() ?: 1728) + put("offScreenRenderingMode", container.getExtra("offScreenRenderingMode", "fbo")) + put("strictShaderMath", container.getExtra("strictShaderMath", "true").toBoolean()) + put("videoMemorySize", container.getExtra("videoMemorySize", "2048")) + put("mouseWarpOverride", container.getExtra("mouseWarpOverride", "disable")) + put("shaderBackend", container.getExtra("shaderBackend", "glsl")) + put("useGLSL", container.getExtra("useGLSL", "enabled")) + put("enableXInput", container.getExtra("enableXInput", "true").toBoolean()) + put("enableDInput", container.getExtra("enableDInput", "true").toBoolean()) + + // Paths + put("executablePath", container.executablePath) + put("installPath", container.installPath) // Include MIDI sound font if set if (container.midiSoundFont.isNotEmpty()) { @@ -141,7 +389,19 @@ object ContainerConfigIO { fun importFromFile(context: Context, sourceUri: Uri): ContainerData? { return try { val jsonString = context.contentResolver.openInputStream(sourceUri)?.use { inputStream -> - inputStream.bufferedReader().use { it.readText() } + // Security: Limit file size to prevent resource exhaustion + val limitedStream = inputStream.buffered().apply { + // Read with size limit + mark(MAX_JSON_SIZE_BYTES + 1) + } + + val content = limitedStream.readBytes(MAX_JSON_SIZE_BYTES) + if (content.size > MAX_JSON_SIZE_BYTES) { + Timber.w("[ContainerConfigIO]: Import file exceeds size limit of $MAX_JSON_SIZE_BYTES bytes") + return null + } + + String(content) } ?: return null importFromJson(jsonString) @@ -151,6 +411,26 @@ object ContainerConfigIO { } } + /** + * Helper to read bytes with a size limit. + */ + private fun java.io.InputStream.readBytes(maxSize: Int): ByteArray { + val buffer = ByteArrayOutputStream() + val data = ByteArray(8192) + var total = 0 + var count: Int + + while (this.read(data).also { count = it } != -1) { + total += count + if (total > maxSize) { + return buffer.toByteArray() + } + buffer.write(data, 0, count) + } + + return buffer.toByteArray() + } + /** * Imports a container configuration from a JSON string. * @@ -159,6 +439,12 @@ object ContainerConfigIO { */ fun importFromJson(jsonString: String): ContainerData? { return try { + // Security: Validate JSON size + if (jsonString.length > MAX_JSON_SIZE_BYTES) { + Timber.w("[ContainerConfigIO]: JSON exceeds size limit") + return null + } + val rootJson = JSONObject(jsonString) // Validate version @@ -171,7 +457,7 @@ object ContainerConfigIO { val config = rootJson.getJSONObject("config") // Parse into ContainerData - ContainerData( + val unsanitized = ContainerData( name = rootJson.optString("containerName", "Imported Config"), screenSize = config.optString("screenSize", Container.DEFAULT_SCREEN_SIZE), envVars = config.optString("envVars", Container.DEFAULT_ENV_VARS), @@ -228,9 +514,13 @@ object ContainerConfigIO { executablePath = config.optString("executablePath", ""), installPath = config.optString("installPath", ""), controllerEmulationBindings = config.optString("controllerEmulationBindings", ""), - ).also { - Timber.i("[ContainerConfigIO]: Successfully imported config '${it.name}'") - } + ) + + // Security: Sanitize all inputs to prevent injection attacks + val sanitized = sanitizeContainerData(unsanitized) + + Timber.i("[ContainerConfigIO]: Successfully imported and sanitized config '${sanitized.name}'") + sanitized } catch (e: Exception) { Timber.e(e, "[ContainerConfigIO]: Failed to parse container config JSON") null @@ -325,6 +615,12 @@ object ContainerConfigIO { return try { val trimmed = shareCode.trim() + // Security: Validate input length + if (trimmed.length > MAX_STRING_LENGTH) { + Timber.w("[ContainerConfigIO]: Share code exceeds maximum length") + return null + } + // Check if it's a URL (gamenative:// or https://) if (trimmed.startsWith("gamenative://") || trimmed.startsWith("https://")) { val uri = Uri.parse(trimmed) @@ -341,10 +637,22 @@ object ContainerConfigIO { // Base64 decode val compressed = Base64.decode(code, Base64.NO_WRAP) - // Decompress + // Security: Validate compressed size to prevent decompression bombs + if (compressed.size > MAX_JSON_SIZE_BYTES) { + Timber.w("[ContainerConfigIO]: Compressed data exceeds size limit") + return null + } + + // Decompress with size limit val jsonString = ByteArrayInputStream(compressed).use { byteStream -> GZIPInputStream(byteStream).use { gzipStream -> - gzipStream.bufferedReader().use { it.readText() } + // Read with size limit to prevent decompression bombs + val decompressed = gzipStream.readBytes(MAX_JSON_SIZE_BYTES) + if (decompressed.size >= MAX_JSON_SIZE_BYTES) { + Timber.w("[ContainerConfigIO]: Decompressed data exceeds size limit") + return null + } + String(decompressed) } } @@ -414,13 +722,31 @@ object ContainerConfigIO { return try { val encodedData = deepLinkUri.getQueryParameter("data") ?: return null + // Security: Validate encoded data length + if (encodedData.length > MAX_STRING_LENGTH) { + Timber.w("[ContainerConfigIO]: Encoded data in deep link exceeds maximum length") + return null + } + // Base64 decode val compressed = Base64.decode(encodedData, Base64.URL_SAFE or Base64.NO_WRAP) - // Decompress + // Security: Validate compressed size + if (compressed.size > MAX_JSON_SIZE_BYTES) { + Timber.w("[ContainerConfigIO]: Compressed data in deep link exceeds size limit") + return null + } + + // Decompress with size limit val jsonString = ByteArrayInputStream(compressed).use { byteStream -> GZIPInputStream(byteStream).use { gzipStream -> - gzipStream.bufferedReader().use { it.readText() } + // Read with size limit to prevent decompression bombs + val decompressed = gzipStream.readBytes(MAX_JSON_SIZE_BYTES) + if (decompressed.size >= MAX_JSON_SIZE_BYTES) { + Timber.w("[ContainerConfigIO]: Decompressed data from deep link exceeds size limit") + return null + } + String(decompressed) } } @@ -447,14 +773,19 @@ object ContainerConfigIO { put("envVars", containerConfig.envVars) put("graphicsDriver", containerConfig.graphicsDriver) put("graphicsDriverVersion", containerConfig.graphicsDriverVersion) + put("graphicsDriverConfig", containerConfig.graphicsDriverConfig) put("dxwrapper", containerConfig.dxwrapper) put("dxwrapperConfig", containerConfig.dxwrapperConfig) put("audioDriver", containerConfig.audioDriver) put("wincomponents", containerConfig.wincomponents) put("drives", containerConfig.drives) put("execArgs", containerConfig.execArgs) + put("executablePath", containerConfig.executablePath) + put("installPath", containerConfig.installPath) put("showFPS", containerConfig.showFPS) put("launchRealSteam", containerConfig.launchRealSteam) + put("allowSteamUpdates", containerConfig.allowSteamUpdates) + put("steamType", containerConfig.steamType) put("cpuList", containerConfig.cpuList) put("cpuListWoW64", containerConfig.cpuListWoW64) put("wow64Mode", containerConfig.wow64Mode) @@ -463,6 +794,42 @@ object ContainerConfigIO { put("box64Version", containerConfig.box64Version) put("box86Preset", containerConfig.box86Preset) put("box64Preset", containerConfig.box64Preset) + put("desktopTheme", containerConfig.desktopTheme) + put("language", containerConfig.language) + put("containerVariant", containerConfig.containerVariant) + put("wineVersion", containerConfig.wineVersion) + put("emulator", containerConfig.emulator) + put("fexcoreVersion", containerConfig.fexcoreVersion) + put("dinputMapperType", containerConfig.dinputMapperType.toInt()) + put("sdlControllerAPI", containerConfig.sdlControllerAPI) + put("disableMouseInput", containerConfig.disableMouseInput) + put("touchscreenMode", containerConfig.touchscreenMode) + put("useDRI3", containerConfig.useDRI3) + put("emulateKeyboardMouse", containerConfig.emulateKeyboardMouse) + put("forceDlc", containerConfig.forceDlc) + + // FEXCore settings + put("fexcoreTSOMode", containerConfig.fexcoreTSOMode) + put("fexcoreX87Mode", containerConfig.fexcoreX87Mode) + put("fexcoreMultiBlock", containerConfig.fexcoreMultiBlock) + + // Wine registry settings + put("renderer", containerConfig.renderer) + put("csmt", containerConfig.csmt) + put("videoPciDeviceID", containerConfig.videoPciDeviceID) + put("offScreenRenderingMode", containerConfig.offScreenRenderingMode) + put("strictShaderMath", containerConfig.strictShaderMath) + put("videoMemorySize", containerConfig.videoMemorySize) + put("mouseWarpOverride", containerConfig.mouseWarpOverride) + put("shaderBackend", containerConfig.shaderBackend) + put("useGLSL", containerConfig.useGLSL) + put("enableXInput", containerConfig.enableXInput) + put("enableDInput", containerConfig.enableDInput) + + // Controller emulation bindings if present + if (containerConfig.controllerEmulationBindings.isNotEmpty()) { + put("controllerEmulationBindings", JSONObject(containerConfig.controllerEmulationBindings)) + } }.toString() return Intent("app.gamenative.LAUNCH_GAME").apply { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be28a8a49..ba09f1c6c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -123,4 +123,8 @@ Resume Pause + + + Failed to import configuration + Share Config