diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eecd5fb2d..66ebde211 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,6 +31,7 @@ + @@ -125,6 +126,18 @@ android:exported="false" android:foregroundServiceType="dataSync" /> + + + + , + selectedBranch: StoreBetaBranchItem?, + onSelect: (StoreBetaBranchItem) -> Unit, + onClose: () -> Unit, +) { + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + .background(BbScrim.copy(alpha = 0.6f)) + .windowInsetsPadding(WindowInsets.navigationBars), + contentAlignment = Alignment.Center, + ) { + val dialogWidth = (maxWidth - 32.dp).coerceAtMost(560.dp) + val dialogMaxHeight = (maxHeight - 48.dp).coerceIn(220.dp, 640.dp) + Surface( + modifier = + Modifier + .widthIn(min = 320.dp, max = dialogWidth) + .fillMaxWidth() + .heightIn(max = dialogMaxHeight), + shape = RoundedCornerShape(14.dp), + color = BbBg, + border = BorderStroke(1.dp, BbBorder), + tonalElevation = 8.dp, + ) { + Column(Modifier.fillMaxWidth()) { + BetaBranchHeader( + gameTitle = gameTitle, + branchCount = branches.size, + onClose = onClose, + ) + HorizontalDivider(color = BbBorder, thickness = 0.5.dp) + LazyColumn( + modifier = Modifier.fillMaxWidth().weight(1f, fill = false), + contentPadding = PaddingValues(vertical = 4.dp), + ) { + itemsIndexed(branches) { index, branch -> + BetaBranchPickerRow( + branch = branch, + selected = branch == selectedBranch, + onClick = { onSelect(branch) }, + ) + if (index < branches.lastIndex) { + HorizontalDivider( + color = Color.White.copy(alpha = 0.06f), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 14.dp), + ) + } + } + } + } + } + } +} + +@Composable +private fun BetaBranchHeader( + gameTitle: String, + branchCount: Int, + onClose: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(BbAccent.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.AltRoute, + contentDescription = null, + tint = BbAccentGlow, + modifier = Modifier.size(19.dp), + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + stringResource(R.string.store_game_beta_branch).uppercase(), + color = BbTextSecondary, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.9.sp, + ) + Text( + gameTitle, + style = MaterialTheme.typography.titleSmall, + color = BbTextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Surface( + modifier = + Modifier.semantics { + contentDescription = "$branchCount branches" + }, + color = BbAccent.copy(alpha = 0.14f), + shape = RoundedCornerShape(7.dp), + ) { + Text( + branchCount.toString(), + color = BbAccentGlow, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp), + ) + } + IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = "Close", + tint = BbTextSecondary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun BetaBranchPickerRow( + branch: StoreBetaBranchItem, + selected: Boolean, + onClick: () -> Unit, +) { + val rowAlpha = if (branch.pwdRequired) 0.45f else 1f + Row( + modifier = + Modifier + .fillMaxWidth() + .then(if (!branch.pwdRequired) Modifier.clickable(onClick = onClick) else Modifier) + .alpha(rowAlpha) + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + val displayName = + if (branch.name.equals("public", ignoreCase = true)) { + "${branch.name} (default)" + } else { + branch.name + } + Text( + displayName, + color = if (selected) BbAccentGlow else BbTextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val dateStr = + remember(branch.timeUpdated) { + branch.timeUpdated + ?.let { SimpleDateFormat("MMM d, yyyy", Locale.US).format(it) } + ?: "—" + } + Text( + "build ${branch.buildId} · $dateStr", + color = BbTextSecondary, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + when { + branch.pwdRequired -> Icon( + Icons.Outlined.Lock, + contentDescription = null, + tint = BbLocked, + modifier = Modifier.size(17.dp), + ) + selected -> Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = BbAccentGlow, + modifier = Modifier.size(18.dp), + ) + } + } +} diff --git a/app/src/main/app/shell/LaunchOptionsScreen.kt b/app/src/main/app/shell/LaunchOptionsScreen.kt new file mode 100644 index 000000000..8309a381b --- /dev/null +++ b/app/src/main/app/shell/LaunchOptionsScreen.kt @@ -0,0 +1,251 @@ +package com.winlator.cmod.app.shell + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.RocketLaunch +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.winlator.cmod.R + +/** A single Steam launch option (appinfo `config.launch` entry). */ +internal data class StoreLaunchOptionItem( + // Relative path, '/'-separated. + val executable: String, + val arguments: String, + val label: String, +) + +// Palette — mirrors the Workshop window so the modal feels native. +private val LoBg = Color(0xFF12121B) +private val LoBorder = Color(0xFF2A2A3A) +private val LoAccent = Color(0xFF1A9FFF) +private val LoAccentGlow = Color(0xFF58A6FF) +private val LoTextPrimary = Color(0xFFF0F4FF) +private val LoTextSecondary = Color(0xFF93A6BC) +private val LoScrim = Color(0xFF000000) + +/** + * Steam launch-option picker — a Workshop-shaped modal window listing the + * game's appinfo `config.launch` entries. Tapping a row persists it as the + * game's default; the check mark moves to confirm and the window stays open. + * + * Stateless: data and callbacks are hoisted to the LaunchOptionsDialog wrapper. + */ +@Composable +internal fun StoreLaunchOptionsScreen( + gameTitle: String, + options: List, + selectedOption: StoreLaunchOptionItem?, + onSelect: (StoreLaunchOptionItem) -> Unit, + onClose: () -> Unit, +) { + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + // Dim the game-detail screen behind so the modal reads as foreground. + .background(LoScrim.copy(alpha = 0.6f)) + .windowInsetsPadding(WindowInsets.navigationBars), + contentAlignment = Alignment.Center, + ) { + val dialogWidth = (maxWidth - 32.dp).coerceAtMost(560.dp) + val dialogMaxHeight = (maxHeight - 48.dp).coerceIn(220.dp, 640.dp) + Surface( + modifier = + Modifier + .widthIn(min = 320.dp, max = dialogWidth) + .fillMaxWidth() + .heightIn(max = dialogMaxHeight), + shape = RoundedCornerShape(14.dp), + color = LoBg, + border = BorderStroke(1.dp, LoBorder), + tonalElevation = 8.dp, + ) { + Column(Modifier.fillMaxWidth()) { + LaunchOptionsHeader( + gameTitle = gameTitle, + optionCount = options.size, + onClose = onClose, + ) + HorizontalDivider(color = LoBorder, thickness = 0.5.dp) + LazyColumn( + // fill = false: the window wraps short lists instead of + // stretching to the max height. + modifier = Modifier.fillMaxWidth().weight(1f, fill = false), + contentPadding = PaddingValues(vertical = 4.dp), + ) { + itemsIndexed(options) { index, option -> + LaunchOptionPickerRow( + option = option, + selected = option == selectedOption, + onClick = { onSelect(option) }, + ) + if (index < options.lastIndex) { + HorizontalDivider( + color = Color.White.copy(alpha = 0.06f), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 14.dp), + ) + } + } + } + } + } + } +} + +@Composable +private fun LaunchOptionsHeader( + gameTitle: String, + optionCount: Int, + onClose: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(LoAccent.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.RocketLaunch, + contentDescription = null, + tint = LoAccentGlow, + modifier = Modifier.size(19.dp), + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + stringResource(R.string.store_game_launch_options).uppercase(), + color = LoTextSecondary, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.9.sp, + ) + Text( + gameTitle, + style = MaterialTheme.typography.titleSmall, + color = LoTextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Surface( + modifier = + Modifier.semantics { + contentDescription = "$optionCount launch options" + }, + color = LoAccent.copy(alpha = 0.14f), + shape = RoundedCornerShape(7.dp), + ) { + Text( + optionCount.toString(), + color = LoAccentGlow, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp), + ) + } + IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = "Close", + tint = LoTextSecondary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun LaunchOptionPickerRow( + option: StoreLaunchOptionItem, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + option.label, + color = if (selected) LoAccentGlow else LoTextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + buildString { + append(option.executable) + if (option.arguments.isNotBlank()) { + append(" · ") + append(option.arguments) + } + }, + color = LoTextSecondary, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = LoAccentGlow, + modifier = Modifier.size(18.dp), + ) + } + } +} diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 6a55ffa25..229309553 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -46,8 +46,11 @@ import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.EmojiEvents import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.AltRoute +import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Save @@ -132,11 +135,15 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, + // Nullable on purpose: null hides the menu item. + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, onPlay: () -> Unit, onSettings: () -> Unit, + onAchievements: (() -> Unit)? = null, onShortcut: () -> Unit, onCloudSaves: () -> Unit, onUninstall: () -> Unit, @@ -272,6 +279,8 @@ internal fun LibraryGameLaunchScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onLaunchOptions = onLaunchOptions, + onBetaBranches = onBetaBranches, ) } @@ -383,6 +392,14 @@ internal fun LibraryGameLaunchScreen( size = actionIconSize, onClick = onSettings, ) + if (onAchievements != null) { + LaunchIconActionButton( + icon = Icons.Outlined.EmojiEvents, + contentDescription = stringResource(R.string.steam_achievements_title), + size = actionIconSize, + onClick = onAchievements, + ) + } LaunchIconActionButton( icon = Icons.Outlined.Home, contentDescription = @@ -799,6 +816,9 @@ private fun SourceTag( onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + // Nullable on purpose: null hides the menu item. + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } @@ -869,6 +889,20 @@ private fun SourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onWorkshop() } } + if (onLaunchOptions != null) { + LaunchSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } + } + if (onBetaBranches != null) { + LaunchSourceMenuItem( + icon = Icons.Outlined.AltRoute, + label = stringResource(R.string.store_game_beta_branch), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onBetaBranches() } + } } } } diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 64b4fbd8e..8836970da 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -57,6 +57,8 @@ import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Extension import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.AltRoute +import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.SystemUpdate @@ -121,6 +123,59 @@ private val StoreTextPrimary = Color(0xFFF0F4FF) private val StoreTextSecondary = Color(0xFF93A6BC) private val StoreDanger = Color(0xFFFF6B6B) +/** + * All StoreGameDetailScreen inputs in one holder. The split exists for ART's + * bytecode verifier: with the 40+-param signature and the screen's body in a + * single method, the frame needed 260+ registers and D8's prologue emitted + * copies this device class rejects (VerifyError: "copy-cat1 v0<-v260" / + * "wide register has type Low-half"). The wrapper below keeps the public + * defaulted signature in a tiny frame; the body lives in a one-param method. + */ +internal class StoreGameDetailParams( + val title: String, + val subtitle: String, + val sourceLabel: String, + val heroImageUrl: Any?, + val isLoading: Boolean, + val isInstalled: Boolean, + val installPathDisplay: String, + val downloadSize: Long, + val installSize: Long, + val availableBytes: Long, + val isInstallEnabled: Boolean, + val isDownloadActionEnabled: Boolean, + val customPathLabel: String, + val showCustomPath: Boolean, + val showCloudSync: Boolean, + val showUninstall: Boolean, + val showUpdateCheck: Boolean, + val isCheckingForUpdate: Boolean, + val isUpdateAvailable: Boolean, + val updateDownloadSize: Long, + val updateStatusText: String?, + val isUpdateActionEnabled: Boolean, + val isUpdateCheckCoolingDown: Boolean, + val showWorkshop: Boolean, + val showVerifyFiles: Boolean, + val areSteamActionsEnabled: Boolean, + val onLaunchOptions: (() -> Unit)?, + val onBetaBranches: (() -> Unit)?, + val dlcs: List, + val selectedDlcIds: Set, + val isDlcSelectionEnabled: Boolean, + val onBack: () -> Unit, + val onInstall: () -> Unit, + val onCheckForUpdate: () -> Unit, + val onWorkshop: () -> Unit, + val onVerifyFiles: () -> Unit, + val onDownloadUpdate: () -> Unit, + val onUninstall: () -> Unit, + val onCloudSync: () -> Unit, + val onCustomPath: () -> Unit, + val onToggleDlc: (Int) -> Unit, + val onToggleSelectAllDlcs: () -> Unit, +) + @Composable internal fun StoreGameDetailScreen( title: String, @@ -149,6 +204,9 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, + // Nullable on purpose: null hides the menu item. + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -164,6 +222,100 @@ internal fun StoreGameDetailScreen( onToggleDlc: (Int) -> Unit = {}, onToggleSelectAllDlcs: () -> Unit = {}, ) { + StoreGameDetailContent( + StoreGameDetailParams( + title = title, + subtitle = subtitle, + sourceLabel = sourceLabel, + heroImageUrl = heroImageUrl, + isLoading = isLoading, + isInstalled = isInstalled, + installPathDisplay = installPathDisplay, + downloadSize = downloadSize, + installSize = installSize, + availableBytes = availableBytes, + isInstallEnabled = isInstallEnabled, + isDownloadActionEnabled = isDownloadActionEnabled, + customPathLabel = customPathLabel, + showCustomPath = showCustomPath, + showCloudSync = showCloudSync, + showUninstall = showUninstall, + showUpdateCheck = showUpdateCheck, + isCheckingForUpdate = isCheckingForUpdate, + isUpdateAvailable = isUpdateAvailable, + updateDownloadSize = updateDownloadSize, + updateStatusText = updateStatusText, + isUpdateActionEnabled = isUpdateActionEnabled, + isUpdateCheckCoolingDown = isUpdateCheckCoolingDown, + showWorkshop = showWorkshop, + showVerifyFiles = showVerifyFiles, + areSteamActionsEnabled = areSteamActionsEnabled, + onLaunchOptions = onLaunchOptions, + onBetaBranches = onBetaBranches, + dlcs = dlcs, + selectedDlcIds = selectedDlcIds, + isDlcSelectionEnabled = isDlcSelectionEnabled, + onBack = onBack, + onInstall = onInstall, + onCheckForUpdate = onCheckForUpdate, + onWorkshop = onWorkshop, + onVerifyFiles = onVerifyFiles, + onDownloadUpdate = onDownloadUpdate, + onUninstall = onUninstall, + onCloudSync = onCloudSync, + onCustomPath = onCustomPath, + onToggleDlc = onToggleDlc, + onToggleSelectAllDlcs = onToggleSelectAllDlcs, + ), + ) +} + +@Composable +private fun StoreGameDetailContent(p: StoreGameDetailParams) { + // Local rebinds keep the body identical to the pre-split parameter names. + val title = p.title + val subtitle = p.subtitle + val sourceLabel = p.sourceLabel + val heroImageUrl = p.heroImageUrl + val isLoading = p.isLoading + val isInstalled = p.isInstalled + val installPathDisplay = p.installPathDisplay + val downloadSize = p.downloadSize + val installSize = p.installSize + val availableBytes = p.availableBytes + val isInstallEnabled = p.isInstallEnabled + val isDownloadActionEnabled = p.isDownloadActionEnabled + val customPathLabel = p.customPathLabel + val showCustomPath = p.showCustomPath + val showCloudSync = p.showCloudSync + val showUninstall = p.showUninstall + val showUpdateCheck = p.showUpdateCheck + val isCheckingForUpdate = p.isCheckingForUpdate + val isUpdateAvailable = p.isUpdateAvailable + val updateDownloadSize = p.updateDownloadSize + val updateStatusText = p.updateStatusText + val isUpdateActionEnabled = p.isUpdateActionEnabled + val isUpdateCheckCoolingDown = p.isUpdateCheckCoolingDown + val showWorkshop = p.showWorkshop + val showVerifyFiles = p.showVerifyFiles + val areSteamActionsEnabled = p.areSteamActionsEnabled + val onLaunchOptions = p.onLaunchOptions + val onBetaBranches = p.onBetaBranches + val dlcs = p.dlcs + val selectedDlcIds = p.selectedDlcIds + val isDlcSelectionEnabled = p.isDlcSelectionEnabled + val onBack = p.onBack + val onInstall = p.onInstall + val onCheckForUpdate = p.onCheckForUpdate + val onWorkshop = p.onWorkshop + val onVerifyFiles = p.onVerifyFiles + val onDownloadUpdate = p.onDownloadUpdate + val onUninstall = p.onUninstall + val onCloudSync = p.onCloudSync + val onCustomPath = p.onCustomPath + val onToggleDlc = p.onToggleDlc + val onToggleSelectAllDlcs = p.onToggleSelectAllDlcs + val context = LocalContext.current val density = LocalDensity.current var dlcExpanded by remember { mutableStateOf(false) } @@ -186,7 +338,11 @@ internal fun StoreGameDetailScreen( val showUpdateCta = updateCheckAvailable && isUpdateAvailable val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled - val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable + val launchOptionsAvailable = onLaunchOptions != null && isInstalled + val betaBranchesAvailable = onBetaBranches != null && isInstalled + val sourceMenuEnabled = + updateCheckAvailable || verifyFilesAvailable || workshopAvailable || + launchOptionsAvailable || betaBranchesAvailable val showDlcCard = dlcs.isNotEmpty() val showActionColumn = showDownloadCta || showUpdateCta || @@ -303,6 +459,8 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onLaunchOptions = if (launchOptionsAvailable) onLaunchOptions else null, + onBetaBranches = if (betaBranchesAvailable) onBetaBranches else null, ) } @@ -500,14 +658,72 @@ internal fun StoreGameDetailScreen( } if (showDownloadCta) { - StoreCtaButton( - height = ctaHeight, - icon = Icons.Outlined.Download, - label = stringResource(R.string.common_ui_download), - enabled = !isLoading && isDownloadActionEnabled, - loading = isLoading, - onClick = onInstall, - ) + if (onBetaBranches != null && !isInstalled) { + // Pre-install: a beta-branch segment "sliced off" the + // Download pill — pick a branch before committing. + // Each segment paints only its slice of one gradient + // spanning the whole row, so the pair reads as the + // single Download pill. + var splitCtaWidthPx by remember { mutableIntStateOf(0) } + val splitCtaLeadPx = + with(LocalDensity.current) { + (actionIconSize + actionIconSpacing).toPx() + } + Row( + modifier = + Modifier + .fillMaxWidth() + .onSizeChanged { splitCtaWidthPx = it.width }, + horizontalArrangement = Arrangement.spacedBy(actionIconSpacing), + ) { + StoreCtaCutoutButton( + height = ctaHeight, + icon = Icons.Outlined.AltRoute, + contentDescription = stringResource(R.string.store_game_beta_branch), + // Same gate as Download: don't stage picks the + // blocked download can't honor. + enabled = !isLoading && isDownloadActionEnabled, + shape = + RoundedCornerShape( + topStart = 14.dp, + bottomStart = 14.dp, + topEnd = 4.dp, + bottomEnd = 4.dp, + ), + onClick = onBetaBranches, + modifier = Modifier.width(actionIconSize), + fillEndX = + splitCtaWidthPx.takeIf { it > 0 }?.toFloat() + ?: Float.POSITIVE_INFINITY, + ) + StoreCtaButton( + height = ctaHeight, + icon = Icons.Outlined.Download, + label = stringResource(R.string.common_ui_download), + enabled = !isLoading && isDownloadActionEnabled, + loading = isLoading, + onClick = onInstall, + modifier = Modifier.weight(1f), + shape = + RoundedCornerShape( + topStart = 4.dp, + bottomStart = 4.dp, + topEnd = 14.dp, + bottomEnd = 14.dp, + ), + fillStartX = -splitCtaLeadPx, + ) + } + } else { + StoreCtaButton( + height = ctaHeight, + icon = Icons.Outlined.Download, + label = stringResource(R.string.common_ui_download), + enabled = !isLoading && isDownloadActionEnabled, + loading = isLoading, + onClick = onInstall, + ) + } } } } @@ -786,6 +1002,9 @@ private fun StoreSourceTag( onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + // Nullable on purpose: null hides the menu item. + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } @@ -861,6 +1080,20 @@ private fun StoreSourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onWorkshop() } } + if (onLaunchOptions != null) { + StoreSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } + } + if (onBetaBranches != null) { + StoreSourceMenuItem( + icon = Icons.Outlined.AltRoute, + label = stringResource(R.string.store_game_beta_branch), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onBetaBranches() } + } } } } @@ -1018,6 +1251,73 @@ private fun StoreActionChip( } } +/** Glass-look brushes shared by StoreCtaButton and StoreCtaCutoutButton. */ +private class StoreCtaGlass( + val fill: Brush, + val sheen: Brush, + val rim: Brush, +) + +private fun storeCtaGlassBrushes( + enabled: Boolean, + flare: Float, + fillStartX: Float = 0f, + fillEndX: Float = Float.POSITIVE_INFINITY, +): StoreCtaGlass = + StoreCtaGlass( + fill = + if (enabled) { + Brush.horizontalGradient( + colors = + listOf( + Color(0xFF00B4D8).copy(alpha = 0.38f), + StoreAccent.copy(alpha = 0.38f), + Color(0xFF7B2FF7).copy(alpha = 0.38f), + ), + startX = fillStartX, + endX = fillEndX, + ) + } else { + Brush.horizontalGradient( + colors = + listOf( + Color(0xFF3A3A4A).copy(alpha = 0.35f), + Color(0xFF2A2A36).copy(alpha = 0.35f), + ), + startX = fillStartX, + endX = fillEndX, + ) + }, + sheen = + if (enabled) { + Brush.verticalGradient( + 0.00f to Color.White.copy(alpha = 0.28f), + 0.35f to Color.White.copy(alpha = 0.06f), + 0.55f to Color.Transparent, + 1.00f to Color.Black.copy(alpha = 0.12f), + ) + } else { + Brush.verticalGradient( + 0.0f to Color.White.copy(alpha = 0.10f), + 0.6f to Color.Transparent, + 1.0f to Color.Black.copy(alpha = 0.08f), + ) + }, + rim = + if (enabled) { + Brush.verticalGradient( + 0.0f to Color.White.copy(alpha = 0.55f + 0.35f * flare), + 0.5f to Color.White.copy(alpha = 0.08f + 0.18f * flare), + 1.0f to Color.White.copy(alpha = 0.22f + 0.22f * flare), + ) + } else { + Brush.verticalGradient( + 0.0f to Color.White.copy(alpha = 0.16f), + 1.0f to Color.White.copy(alpha = 0.04f), + ) + }, + ) + @Composable private fun StoreCtaButton( height: Dp, @@ -1026,6 +1326,10 @@ private fun StoreCtaButton( enabled: Boolean, loading: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier.fillMaxWidth(), + shape: RoundedCornerShape = RoundedCornerShape(14.dp), + fillStartX: Float = 0f, + fillEndX: Float = Float.POSITIVE_INFINITY, ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -1039,64 +1343,18 @@ private fun StoreCtaButton( animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f), label = "storeCtaFlare", ) - val shape = remember { RoundedCornerShape(14.dp) } - val activeBrush = - Brush.horizontalGradient( - colors = - listOf( - Color(0xFF00B4D8).copy(alpha = 0.38f), - StoreAccent.copy(alpha = 0.38f), - Color(0xFF7B2FF7).copy(alpha = 0.38f), - ), - ) - val disabledBrush = - Brush.horizontalGradient( - colors = - listOf( - Color(0xFF3A3A4A).copy(alpha = 0.35f), - Color(0xFF2A2A36).copy(alpha = 0.35f), - ), - ) - val glassSheenBrush = - if (enabled) { - Brush.verticalGradient( - 0.00f to Color.White.copy(alpha = 0.28f), - 0.35f to Color.White.copy(alpha = 0.06f), - 0.55f to Color.Transparent, - 1.00f to Color.Black.copy(alpha = 0.12f), - ) - } else { - Brush.verticalGradient( - 0.0f to Color.White.copy(alpha = 0.10f), - 0.6f to Color.Transparent, - 1.0f to Color.Black.copy(alpha = 0.08f), - ) - } - val glassRimBrush = - if (enabled) { - Brush.verticalGradient( - 0.0f to Color.White.copy(alpha = 0.55f + 0.35f * flare), - 0.5f to Color.White.copy(alpha = 0.08f + 0.18f * flare), - 1.0f to Color.White.copy(alpha = 0.22f + 0.22f * flare), - ) - } else { - Brush.verticalGradient( - 0.0f to Color.White.copy(alpha = 0.16f), - 1.0f to Color.White.copy(alpha = 0.04f), - ) - } + val glass = storeCtaGlassBrushes(enabled, flare, fillStartX, fillEndX) Box( modifier = - Modifier - .fillMaxWidth() + modifier .height(height) .graphicsLayer { scaleX = scale scaleY = scale }.clip(shape) - .background(if (enabled) activeBrush else disabledBrush) - .background(glassSheenBrush) - .border(1.dp, glassRimBrush, shape) + .background(glass.fill) + .background(glass.sheen) + .border(1.dp, glass.rim, shape) .clickable( interactionSource = interactionSource, indication = null, @@ -1134,6 +1392,62 @@ private fun StoreCtaButton( } } +/** + * The small segment "sliced off" the Download pill: same glass look, icon only. + * Opens the beta-branch picker before a download is committed. + */ +@Composable +private fun StoreCtaCutoutButton( + height: Dp, + icon: ImageVector, + contentDescription: String, + enabled: Boolean, + shape: RoundedCornerShape, + onClick: () -> Unit, + modifier: Modifier = Modifier, + fillStartX: Float = 0f, + fillEndX: Float = Float.POSITIVE_INFINITY, +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed && enabled) 0.96f else 1f, + animationSpec = spring(dampingRatio = 0.5f, stiffness = 600f), + label = "storeCtaCutoutScale", + ) + val flare by animateFloatAsState( + targetValue = if (isPressed && enabled) 1f else 0f, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f), + label = "storeCtaCutoutFlare", + ) + val glass = storeCtaGlassBrushes(enabled, flare, fillStartX, fillEndX) + Box( + modifier = + modifier + .height(height) + .graphicsLayer { + scaleX = scale + scaleY = scale + }.clip(shape) + .background(glass.fill) + .background(glass.sheen) + .border(1.dp, glass.rim, shape) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { if (enabled) onClick() }, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + icon, + contentDescription = contentDescription, + modifier = Modifier.size(24.dp), + tint = Color.White, + ) + } +} + @Composable private fun StoreIconActionButton( icon: ImageVector, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 13e32d4e9..f7d056b96 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -216,6 +216,7 @@ import com.winlator.cmod.shared.theme.WinNativeTheme import dagger.hilt.android.AndroidEntryPoint import dagger.Lazy import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -370,6 +371,7 @@ class UnifiedActivity : lifecycleScope.launch { val result = runCatching { + // checkForAppUpdate defaults to the game's selected beta branch. withContext(Dispatchers.IO) { SteamService.checkForAppUpdate(appId) } }.getOrNull() try { @@ -381,8 +383,16 @@ class UnifiedActivity : } result.hasUpdate -> { val started = - withContext(Dispatchers.IO) { - SteamService.downloadAppForUpdate(appId, result.depotIds) + runCatching { + withContext(Dispatchers.IO) { + SteamService.downloadAppForUpdate(appId, result.depotIds) + } + }.getOrElse { e -> + Log.w("UnifiedActivity", "Steam update download failed to start for appId=$appId", e) + taskCheckingShown = false + taskDoneFailed = true + taskDoneMessage = getString(R.string.store_game_update_failed_notice) + return@launch } if (started != null) { showTaskProgressPopup( @@ -1414,6 +1424,32 @@ class UnifiedActivity : val persona by SteamService.instance?.localPersona?.collectAsState() ?: remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val rightDrawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val friends by SteamService.instance?.friendsList?.collectAsState() + ?: remember { mutableStateOf(emptyList()) } + var chatFriend by remember { mutableStateOf(null) } + val friendsDrawerOpen = rightDrawerState.isOpen + LaunchedEffect(isLoggedIn) { + if (isLoggedIn) { + while (true) { + runCatching { SteamService.instance?.refreshFriends() } + kotlinx.coroutines.delay(30_000L) + } + } + } + LaunchedEffect(isLoggedIn, friendsDrawerOpen) { + if (isLoggedIn && friendsDrawerOpen) { + while (true) { + runCatching { SteamService.instance?.syncFriendsPresence() } + kotlinx.coroutines.delay(5_000L) + } + } + } + LaunchedEffect(isLoggedIn) { + if (isLoggedIn) { + runCatching { com.winlator.cmod.feature.stores.steam.chat.ChatOverlayService.start(context) } + } + } val epicApps by db.epicGameDao().getAll().collectAsState(initial = emptyList()) val gogApps by db.gogGameDao().getAll().collectAsState(initial = emptyList()) @@ -1641,6 +1677,50 @@ class UnifiedActivity : } } + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Rtl, + ) { + ModalNavigationDrawer( + drawerState = rightDrawerState, + drawerContent = { + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr, + ) { + com.winlator.cmod.feature.stores.steam.friends.FriendsDrawerContent( + self = persona ?: com.winlator.cmod.feature.stores.steam.data.SteamFriend(), + friends = friends, + onSetState = { st -> scope.launch { SteamService.setPersonaState(st) } }, + onOpenChat = { f -> chatFriend = f; scope.launch { rightDrawerState.close() } }, + onJoinGame = { f -> + scope.launch { rightDrawerState.close() } + scope.launch { + val app = withContext(Dispatchers.IO) { SteamService.getAppInfoOf(f.gameAppId) } + val installed = withContext(Dispatchers.IO) { SteamService.getInstalledApp(f.gameAppId) } + val label = f.gameName.ifBlank { context.getString(R.string.steam_join_the_game) } + if (app != null && installed != null) { + android.widget.Toast.makeText( + context, context.getString(R.string.steam_join_joining, f.name, label), android.widget.Toast.LENGTH_SHORT, + ).show() + launchSteamGame(context, ContainerManager(context), app, f.connectString) + } else { + android.widget.Toast.makeText( + context, + if (app != null) context.getString(R.string.steam_join_install, label, f.name) + else context.getString(R.string.steam_join_not_owned, label), + android.widget.Toast.LENGTH_LONG, + ).show() + } + } + }, + ) + } + }, + scrimColor = Color.Black.copy(alpha = 0.5f), + gesturesEnabled = rightDrawerState.isOpen, + ) { + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr, + ) { ModalNavigationDrawer( drawerState = drawerState, drawerContent = { @@ -1739,7 +1819,7 @@ class UnifiedActivity : }, persona, context, scope, isControllerConnected, isPS, isLibraryTab, searchQueryTfv, { searchQueryTfv = it - }, onFilterClicked = { scope.launch { drawerState.open() } }) { + }, onFilterClicked = { scope.launch { drawerState.open() } }, onFriendsClicked = { scope.launch { rightDrawerState.open() } }) { if (selectedLibrarySource == "GOG") { globalSettingsGogGame = gogApps.find { it.id == selectedGogGameId } } else { @@ -1850,6 +1930,21 @@ class UnifiedActivity : val addGameFabMargin = (libraryFabBase * 0.035f).dp.coerceIn(12.dp, 20.dp) val addGameFabIconSize = (libraryFabBase * 0.055f).dp.coerceIn(24.dp, 28.dp) + if (drawerState.isClosed) { + DrawerSwipeHotZone( + modifier = Modifier.align(Alignment.CenterStart), + onOpenDrawer = { scope.launch { drawerState.open() } }, + ) + } + if (rightDrawerState.isClosed) { + DrawerSwipeHotZone( + modifier = Modifier.align(Alignment.CenterEnd).padding(end = 22.dp), + isRightSide = true, + onOpenDrawer = { scope.launch { rightDrawerState.open() } }, + ) + } + + // Composed after the hot zones so the FAB stays on top for hit-testing. if (key == "library") { Box( modifier = @@ -1876,17 +1971,13 @@ class UnifiedActivity : ) } } - - if (drawerState.isClosed) { - DrawerSwipeHotZone( - modifier = Modifier.align(Alignment.CenterStart), - onOpenDrawer = { scope.launch { drawerState.open() } }, - ) - } } } } } // end ModalNavigationDrawer + } // end inner LTR + } // end right friends ModalNavigationDrawer + } // end RTL provider if (globalSettingsApp != null) { GameSettingsDialog( @@ -1908,8 +1999,19 @@ class UnifiedActivity : }) } + chatFriend?.let { cf -> + com.winlator.cmod.feature.stores.steam.friends.SteamChatScreen( + friend = friends.firstOrNull { it.steamId == cf.steamId } ?: cf, + onClose = { chatFriend = null }, + ) + } + BackHandler(enabled = true) { - if (drawerState.isOpen) { + if (chatFriend != null) { + chatFriend = null + } else if (rightDrawerState.isOpen) { + scope.launch { rightDrawerState.close() } + } else if (drawerState.isOpen) { scope.launch { drawerState.close() } } else if (globalSettingsApp != null) { globalSettingsApp = null @@ -1977,6 +2079,7 @@ class UnifiedActivity : @Composable private fun DrawerSwipeHotZone( modifier: Modifier = Modifier, + isRightSide: Boolean = false, onOpenDrawer: () -> Unit, ) { val density = LocalDensity.current @@ -1986,8 +2089,8 @@ class UnifiedActivity : modifier = modifier .fillMaxHeight() - .width(40.dp) - .pointerInput(openThresholdPx) { + .width(if (isRightSide) 30.dp else 40.dp) + .pointerInput(openThresholdPx, isRightSide) { var accumulatedDrag = 0f var opened = false @@ -1997,9 +2100,10 @@ class UnifiedActivity : opened = false }, onHorizontalDrag = { change, dragAmount -> - if (dragAmount <= 0f || opened) return@detectHorizontalDragGestures + val delta = if (isRightSide) -dragAmount else dragAmount + if (delta <= 0f || opened) return@detectHorizontalDragGestures - accumulatedDrag += dragAmount + accumulatedDrag += delta change.consume() if (accumulatedDrag >= openThresholdPx) { @@ -2026,6 +2130,7 @@ class UnifiedActivity : searchQuery: TextFieldValue, onSearchQueryChange: (TextFieldValue) -> Unit, onFilterClicked: () -> Unit, + onFriendsClicked: () -> Unit = {}, onGameSettingsClicked: () -> Unit, ) { var isSearchExpanded by remember { mutableStateOf(false) } @@ -2258,6 +2363,20 @@ class UnifiedActivity : Spacer(Modifier.width(8.dp)) + Box( + modifier = + Modifier + .size(44.dp) + .shadow(6.dp, CircleShape, spotColor = Color.Black.copy(alpha = 0.5f)) + .clip(CircleShape) + .background(SurfaceDark) + .clickable { onFriendsClicked() }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.People, contentDescription = "Friends", tint = TextPrimary, modifier = Modifier.size(24.dp)) + } + Spacer(Modifier.width(8.dp)) + Box( modifier = Modifier @@ -4325,6 +4444,7 @@ class UnifiedActivity : val scope = rememberCoroutineScope() var currentScreen by remember { mutableStateOf(LibraryDetailScreen.Main) } var activePopup by remember { mutableStateOf(null) } + var showAchievements by remember(app.id) { mutableStateOf(false) } var shortcutRefreshKey by remember(app.id, gogGame?.id) { mutableStateOf(0) } var pinnedShortcutOverride by remember(app.id, gogGame?.id) { mutableStateOf(null) } var showWorkshopDialog by remember(app.id) { mutableStateOf(false) } @@ -4333,6 +4453,39 @@ class UnifiedActivity : val isEpic = app.id >= 2000000000 val isGog = gogGame != null val epicId = if (isEpic) app.id - 2000000000 else 0 + val isSteamLibraryGame = !isCustom && !isEpic && !isGog + + var showLaunchOptionsDialog by remember(app.id) { mutableStateOf(false) } + var launchOptions by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedLaunchOption by remember(app.id) { mutableStateOf(null) } + var showBetaBranchesDialog by remember(app.id) { mutableStateOf(false) } + var betaBranches by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedBetaBranch by remember(app.id) { mutableStateOf(null) } + LaunchedEffect(app.id, isSteamLibraryGame) { + val steamInstalled = + isSteamLibraryGame && withContext(Dispatchers.IO) { SteamService.isAppInstalled(app.id) } + if (!steamInstalled) { + launchOptions = emptyList() + selectedLaunchOption = null + betaBranches = emptyList() + selectedBetaBranch = null + return@LaunchedEffect + } + // Degrade to hidden menu items (logged) rather than crash the dialog. + runCatching { + loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> + launchOptions = options + selectedLaunchOption = selected + } + loadSteamBetaBranchesRefreshing(app.id) { branches, selected -> + betaBranches = branches + selectedBetaBranch = selected + } + }.onFailure { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + Log.e("UnifiedActivity", "Steam game-detail extras load failed for appId=${app.id}", e) + } + } val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), @@ -5002,6 +5155,9 @@ class UnifiedActivity : ShortcutSettingsComposeDialog(this@UnifiedActivity, shortcut).show() } }, + onAchievements = if (!isCustom && !isEpic && !isGog) { + { showAchievements = true } + } else null, onShortcut = { if (hasPinnedShortcut) { currentScreen = LibraryDetailScreen.Shortcut @@ -5053,6 +5209,18 @@ class UnifiedActivity : (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, + onLaunchOptions = + if (launchOptions.size >= 2) { + { showLaunchOptionsDialog = true } + } else { + null + }, + onBetaBranches = + if (betaBranches.size >= 2) { + { showBetaBranchesDialog = true } + } else { + null + }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -5310,6 +5478,22 @@ class UnifiedActivity : } } + if (showAchievements) { + Dialog( + onDismissRequest = { showAchievements = false }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + ), + ) { + com.winlator.cmod.feature.stores.steam.achievements.SteamAchievementsScreen( + appId = app.id, + appName = app.name, + onClose = { showAchievements = false }, + ) + } + } + activePopup?.let { popup -> LibraryDetailPopupFrame( title = @@ -5478,6 +5662,40 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + if (showLaunchOptionsDialog) { + LaunchOptionsDialog( + appId = app.id, + gameTitle = app.name, + options = launchOptions, + selectedOption = selectedLaunchOption, + onSelectionSaved = { selectedLaunchOption = it }, + onDismissRequest = { showLaunchOptionsDialog = false }, + ) + } + + if (showBetaBranchesDialog) { + BetaBranchesDialog( + gameTitle = app.name, + branches = betaBranches, + selectedBranch = selectedBetaBranch, + onSelect = { item -> + persistSteamBetaBranchSelection( + appId = app.id, + item = item, + scope = scope, + onSaved = { saved -> + selectedBetaBranch = saved + // Close the picker so the update-check flow it + // triggers is what the user sees next. + showBetaBranchesDialog = false + }, + startUpdate = { startUpdateCheck(app.id, app.name) }, + ) + }, + onDismissRequest = { showBetaBranchesDialog = false }, + ) + } } } } @@ -8467,6 +8685,191 @@ class UnifiedActivity : } } + // Apps whose appinfo was already re-fetched this process (see + // refreshAppInfoFromPicsOnce) — avoids a PICS round-trip on every + // game-detail open. + private val appinfoRefreshedApps = java.util.Collections.synchronizedSet(mutableSetOf()) + + /** + * Builds the Steam launch-option list (appinfo config.launch) for the STEAM + * dropdown on both game detail screens, plus the currently effective selection. + */ + private suspend fun loadSteamLaunchOptions(appId: Int): Pair, StoreLaunchOptionItem?> = + withContext(Dispatchers.IO) { + val appDir = java.io.File(SteamService.getAppDirPath(appId)) + val allOptions = + SteamService + .getWindowsLaunchInfos(appId) + .map { info -> + StoreLaunchOptionItem( + executable = info.executable, + arguments = info.arguments, + label = info.description.ifBlank { info.executable.substringAfterLast('/') }, + ) + } + // Label is part of the key: cached appinfo rows predating + // LaunchInfo.arguments have "" args, and exe+args alone would + // collapse distinct options like "Play (DX11)" / "Play (DX12)". + .distinctBy { Triple(it.executable.lowercase(), it.arguments, it.label) } + // Hide options whose exe is missing on disk, but if that empties a + // non-empty list (case-sensitivity quirks) keep the unfiltered set. + val onDisk = allOptions.filter { java.io.File(appDir, it.executable).isFile } + val options = onDisk.ifEmpty { allOptions } + val (selectedExe, selectedArgs) = SteamService.getSelectedLaunchOption(applicationContext, appId) + val selected = + options.firstOrNull { + it.executable.equals(selectedExe, ignoreCase = true) && it.arguments == selectedArgs + } ?: options.firstOrNull { it.executable.equals(selectedExe, ignoreCase = true) } + ?: options.firstOrNull() + options to selected + } + + /** + * Re-fetches [appId]'s PICS appinfo at most once per process — shared by + * the launch-options and beta-branch loaders so one round-trip feeds both. + * Returns true when this call performed the refresh; a failed fetch + * (offline) releases the slot so the next game-detail open retries. + */ + private suspend fun refreshAppInfoFromPicsOnce(appId: Int): Boolean { + if (!appinfoRefreshedApps.add(appId)) return false + if (SteamService.refreshAppInfoFromPics(appId)) return true + appinfoRefreshedApps.remove(appId) + return false + } + + /** + * Loads launch options, then — once per app per process — re-fetches the + * app's PICS appinfo to heal cached rows that predate LaunchInfo.arguments + * and re-applies the fresh list. [apply] runs on the caller's context. + */ + private suspend fun loadSteamLaunchOptionsRefreshing( + appId: Int, + apply: (List, StoreLaunchOptionItem?) -> Unit, + ) { + val (options, selected) = loadSteamLaunchOptions(appId) + apply(options, selected) + if (refreshAppInfoFromPicsOnce(appId)) { + val (fresh, freshSelected) = loadSteamLaunchOptions(appId) + apply(fresh, freshSelected) + } + } + + private fun persistSteamLaunchOptionSelection( + appId: Int, + option: StoreLaunchOptionItem, + scope: CoroutineScope, + onSaved: (StoreLaunchOptionItem) -> Unit, + ) { + scope.launch(Dispatchers.IO) { + val saved = + SteamService.setSelectedLaunchOption( + applicationContext, + appId, + option.executable, + option.arguments, + ) + withContext(Dispatchers.Main) { + if (saved) { + onSaved(option) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this@UnifiedActivity, + getString(R.string.store_game_launch_option_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + } + + private suspend fun loadSteamBetaBranches(appId: Int): Pair, StoreBetaBranchItem?> = + withContext(Dispatchers.IO) { + val branches = + SteamService.getAppInfoOf(appId) + ?.branches + ?.values + ?.map { b -> StoreBetaBranchItem(b.name, b.buildId, b.timeUpdated, b.pwdRequired) } + ?.sortedWith(compareBy({ !it.name.equals("public", ignoreCase = true) }, { it.name })) + .orEmpty() + // recoverSelectedBetaName also heals selections lost to an app + // reinstall (shortcuts are app-private; game files survive). + val selectedName = SteamService.recoverSelectedBetaName(appId).ifBlank { "public" } + val selected = + branches.firstOrNull { it.name.equals(selectedName, ignoreCase = true) } + // Selected beta may have been retired from PICS — fall back to public. + ?: branches.firstOrNull { it.name.equals("public", ignoreCase = true) } + branches to selected + } + + /** + * Loads beta branches, then — once per app per process — re-fetches the + * app's PICS appinfo for current branch build ids and re-applies the + * fresh list. [apply] runs on the caller's context. + */ + private suspend fun loadSteamBetaBranchesRefreshing( + appId: Int, + apply: (List, StoreBetaBranchItem?) -> Unit, + ) { + val (branches, selected) = loadSteamBetaBranches(appId) + apply(branches, selected) + if (refreshAppInfoFromPicsOnce(appId)) { + val (fresh, freshSelected) = loadSteamBetaBranches(appId) + apply(fresh, freshSelected) + } + } + + /** + * Commits a staged pre-install branch pick right before the download starts, + * so downloadApp (and any later resume) resolves it from the shortcut. + * Custom path is persisted first: the shortcut snapshots game_install_path + * at creation and is never rewritten. On failure (no usable container yet) + * the install proceeds on the public branch rather than blocking. + */ + private suspend fun commitPendingBetaBranch( + appId: Int, + item: StoreBetaBranchItem, + customPath: String?, + ) { + customPath?.let { SteamService.setCustomInstallPath(appId, it) } + val branchName = if (item.name.equals("public", ignoreCase = true)) "" else item.name + if (!SteamService.setSelectedBetaBranch(applicationContext, appId, branchName)) { + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this@UnifiedActivity, + getString(R.string.store_game_beta_branch_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + + private fun persistSteamBetaBranchSelection( + appId: Int, + item: StoreBetaBranchItem, + scope: CoroutineScope, + onSaved: (StoreBetaBranchItem) -> Unit, + startUpdate: () -> Unit, + ) { + scope.launch(Dispatchers.IO) { + // "public" is the implicit default — store blank so resolveSelectedBetaName + // keeps returning "" for games the user never switched. + val branchName = if (item.name.equals("public", ignoreCase = true)) "" else item.name + val saved = SteamService.setSelectedBetaBranch(applicationContext, appId, branchName) + withContext(Dispatchers.Main) { + if (saved) { + onSaved(item) + startUpdate() + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this@UnifiedActivity, + getString(R.string.store_game_beta_branch_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + } + // Game Manager Dialog @Composable fun GameManagerDialog( @@ -8486,6 +8889,14 @@ class UnifiedActivity : var isCheckingForUpdate by remember(app.id) { mutableStateOf(false) } var isUpdateCheckCoolingDown by remember(app.id) { mutableStateOf(false) } var showWorkshopDialog by remember(app.id) { mutableStateOf(false) } + var showLaunchOptionsDialog by remember(app.id) { mutableStateOf(false) } + var launchOptions by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedLaunchOption by remember(app.id) { mutableStateOf(null) } + var showBetaBranchesDialog by remember(app.id) { mutableStateOf(false) } + var betaBranches by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedBetaBranch by remember(app.id) { mutableStateOf(null) } + // Pre-install branch choice, staged until the Download button commits it. + var pendingBetaBranch by remember(app.id) { mutableStateOf(null) } var updateInfo by remember(app.id) { mutableStateOf(null) } var updateStatusText by remember(app.id) { mutableStateOf(null) } val downloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( @@ -8515,13 +8926,18 @@ class UnifiedActivity : val installed: Boolean, ) - LaunchedEffect(app.id, downloadRecords) { + LaunchedEffect(app.id, downloadRecords, pendingBetaBranch) { val loadData = withContext(Dispatchers.IO) { + // Size the same branch the download will actually fetch — the + // staged pre-install pick wins over the persisted selection. + val branch = + pendingBetaBranch?.name + ?: SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } val selectableDlcApps = SteamService.getSelectableDlcAppsOf(app.id) val perDlcSizes = selectableDlcApps.associate { dlc -> - dlc.id to SteamService.getDlcOnlyManifestSizes(app.id, dlc.id) + dlc.id to SteamService.getDlcOnlyManifestSizes(app.id, dlc.id, branch = branch) } val installedDlcIds = SteamService.getInstalledDlcDepotsOf(app.id) @@ -8531,7 +8947,7 @@ class UnifiedActivity : dlcApps = selectableDlcApps, dlcSizes = perDlcSizes, installedDlcIds = installedDlcIds, - baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id), + baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id, branch = branch), installed = SteamService.isAppInstalled(app.id), ) } @@ -8544,13 +8960,48 @@ class UnifiedActivity : isLoading = false } - LaunchedEffect(app.id, selectedDlcIds.toList()) { + LaunchedEffect(app.id, selectedDlcIds.toList(), pendingBetaBranch) { selectedManifestSizes = withContext(Dispatchers.IO) { - SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList()) + val branch = + pendingBetaBranch?.name + ?: SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } + SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList(), branch = branch) } } + LaunchedEffect(app.id, installed) { + // Wait for the install state so the pre-install branch cutout can't + // flash on a game that then resolves to installed. + if (installed == null) return@LaunchedEffect + if (installed == true) { + // A staged pre-install pick is meaningless once the game is + // installed (it was either committed by Download or abandoned). + pendingBetaBranch = null + } else { + launchOptions = emptyList() + selectedLaunchOption = null + } + // Degrade to hidden menu items (logged) rather than crash the dialog. + runCatching { + if (installed == true) { + loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> + launchOptions = options + selectedLaunchOption = selected + } + } + // Branches load for both states: installed games switch via the + // STEAM menu, uninstalled games via the Download-button cutout. + loadSteamBetaBranchesRefreshing(app.id) { branches, selected -> + betaBranches = branches + selectedBetaBranch = selected + } + }.onFailure { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + Log.e("UnifiedActivity", "Steam game-detail extras load failed for appId=${app.id}", e) + } + } + val totalDownloadSize = selectedManifestSizes.downloadSize val totalInstallSize = selectedManifestSizes.installSize val defaultPathSet = @@ -8671,6 +9122,18 @@ class UnifiedActivity : showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, + onLaunchOptions = + if (launchOptions.size >= 2) { + { showLaunchOptionsDialog = true } + } else { + null + }, + onBetaBranches = + if (betaBranches.size >= 2) { + { showBetaBranchesDialog = true } + } else { + null + }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, @@ -8689,6 +9152,9 @@ class UnifiedActivity : val installableDlcIds = dlcItems .filter { !it.isInstalled && it.id in selectedDlcIds } .map { it.id } + if (installed != true) { + pendingBetaBranch?.let { commitPendingBetaBranch(app.id, it, customPath) } + } SteamService.downloadApp(app.id, installableDlcIds, false, customPath) withContext(Dispatchers.Main) { onDismissRequest() } } @@ -8809,6 +9275,114 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + if (showLaunchOptionsDialog) { + LaunchOptionsDialog( + appId = app.id, + gameTitle = app.name, + options = launchOptions, + selectedOption = selectedLaunchOption, + onSelectionSaved = { selectedLaunchOption = it }, + onDismissRequest = { showLaunchOptionsDialog = false }, + ) + } + + if (showBetaBranchesDialog) { + BetaBranchesDialog( + gameTitle = app.name, + branches = betaBranches, + // pendingBetaBranch is null for installed games (cleared on install). + selectedBranch = pendingBetaBranch ?: selectedBetaBranch, + onSelect = { item -> + if (installed == true) { + persistSteamBetaBranchSelection( + appId = app.id, + item = item, + scope = scope, + onSaved = { saved -> + selectedBetaBranch = saved + // Close the picker so the update-check flow it + // triggers is what the user sees next. + showBetaBranchesDialog = false + }, + startUpdate = { startUpdateCheck(app.id, app.name) }, + ) + } else { + // Pre-install: stage only — the Download button commits. + pendingBetaBranch = item + showBetaBranchesDialog = false + } + }, + onDismissRequest = { showBetaBranchesDialog = false }, + ) + } + } + + /** + * Hosts the Workshop-styled launch-option picker window over a game detail + * dialog. Selecting a row persists it as the game's default and reports the + * saved option through [onSelectionSaved]. + */ + @Composable + private fun LaunchOptionsDialog( + appId: Int, + gameTitle: String, + options: List, + selectedOption: StoreLaunchOptionItem?, + onSelectionSaved: (StoreLaunchOptionItem) -> Unit, + onDismissRequest: () -> Unit, + ) { + val scope = rememberCoroutineScope() + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreLaunchOptionsScreen( + gameTitle = gameTitle, + options = options, + selectedOption = selectedOption, + onSelect = { option -> + persistSteamLaunchOptionSelection(appId, option, scope, onSelectionSaved) + }, + onClose = onDismissRequest, + ) + } + } + + /** + * Hosts the Workshop-styled beta-branch picker window over a game detail + * dialog. What a row tap means is the host's call via [onSelect]: installed + * games persist + start the update flow, pre-install games just stage the + * choice until the Download button commits it. + */ + @Composable + private fun BetaBranchesDialog( + gameTitle: String, + branches: List, + selectedBranch: StoreBetaBranchItem?, + onSelect: (StoreBetaBranchItem) -> Unit, + onDismissRequest: () -> Unit, + ) { + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreBetaBranchScreen( + gameTitle = gameTitle, + branches = branches, + selectedBranch = selectedBranch, + onSelect = onSelect, + onClose = onDismissRequest, + ) + } } @Composable @@ -9355,6 +9929,7 @@ class UnifiedActivity : context: android.content.Context, containerManager: ContainerManager, app: SteamApp, + joinConnect: String? = null, ) { lifecycleScope.launch(Dispatchers.IO) { val gameInstallPath = SteamService.getAppDirPath(app.id) @@ -9417,6 +9992,7 @@ class UnifiedActivity : intent.putExtra("container_id", shortcut.container.id) intent.putExtra("shortcut_path", shortcut.file.path) intent.putExtra("shortcut_name", shortcut.name) + if (!joinConnect.isNullOrBlank()) intent.putExtra("steam_join_connect", joinConnect) withContext(Dispatchers.Main) { launchGame(context, intent) } @@ -9461,6 +10037,7 @@ class UnifiedActivity : intent.putExtra("container_id", container.id) intent.putExtra("shortcut_path", shortcutFile.path) intent.putExtra("shortcut_name", app.name) + if (!joinConnect.isNullOrBlank()) intent.putExtra("steam_join_connect", joinConnect) withContext(Dispatchers.Main) { launchGame(context, intent) } @@ -10242,8 +10819,6 @@ class UnifiedActivity : onImmersiveBlurChanged: (Boolean) -> Unit, onExitApp: () -> Unit, ) { - val currentState = persona?.state ?: EPersonaState.Online - var statusExpanded by remember { mutableStateOf(false) } ModalDrawerSheet( drawerShape = RectangleShape, @@ -10259,176 +10834,6 @@ class UnifiedActivity : .verticalScroll(rememberScrollState()) .padding(20.dp), ) { - // ── Avatar Card ── - Surface( - shape = RoundedCornerShape(16.dp), - color = SurfaceDark, - border = BorderStroke(1.dp, CardBorder), - modifier = - Modifier - .fillMaxWidth() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { statusExpanded = !statusExpanded }, - ) { - Column(Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - val avatarUrl = - persona?.avatarHash?.getAvatarURL() - ?: "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg" - - Box( - modifier = - Modifier - .size(48.dp) - .clip(CircleShape), - ) { - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(avatarUrl) - .crossfade(true) - .build(), - contentDescription = "Profile", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } - - Spacer(Modifier.width(12.dp)) - - Column(Modifier.weight(1f)) { - Text( - text = persona?.name ?: stringResource(R.string.stores_accounts_not_signed_in), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = TextPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - val statusLabel = - when (currentState) { - EPersonaState.Online -> stringResource(R.string.stores_accounts_status_online) - EPersonaState.Away -> stringResource(R.string.stores_accounts_status_away) - else -> stringResource(R.string.stores_accounts_status_offline) - } - val statusColor = - when (currentState) { - EPersonaState.Online -> StatusOnline - EPersonaState.Away -> StatusAway - else -> StatusOffline - } - Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(8.dp).background(statusColor, CircleShape)) - Spacer(Modifier.width(6.dp)) - Text(statusLabel, style = MaterialTheme.typography.bodySmall, color = TextSecondary) - } - } - - val chevronRotation by animateFloatAsState( - targetValue = if (statusExpanded) 90f else 0f, - animationSpec = tween(250), - label = "chevronRotation", - ) - Icon( - Icons.Outlined.ChevronRight, - contentDescription = "Toggle status", - tint = TextSecondary, - modifier = - Modifier - .size(20.dp) - .graphicsLayer { rotationZ = chevronRotation }, - ) - } - - // Expandable status options - AnimatedVisibility(visible = statusExpanded) { - Column(Modifier.padding(top = 12.dp)) { - HorizontalDivider(color = TextSecondary.copy(alpha = 0.2f)) - Spacer(Modifier.height(8.dp)) - Text( - stringResource(R.string.stores_accounts_status_header), - style = MaterialTheme.typography.labelSmall, - color = TextSecondary, - ) - Spacer(Modifier.height(8.dp)) - - listOf( - Triple(EPersonaState.Online, stringResource(R.string.stores_accounts_status_online), StatusOnline), - Triple(EPersonaState.Away, stringResource(R.string.stores_accounts_status_away), StatusAway), - Triple( - EPersonaState.Invisible, - stringResource(R.string.stores_accounts_status_invisible), - StatusOffline, - ), - ).forEach { (state, label, color) -> - val isSelected = currentState == state - val rowBg by animateColorAsState( - targetValue = if (isSelected) Accent.copy(alpha = 0.12f) else Color.Transparent, - animationSpec = tween(250), - label = "statusRowBg", - ) - val borderAlpha by animateFloatAsState( - targetValue = if (isSelected) 1f else 0f, - animationSpec = tween(250), - label = "statusBorder", - ) - val checkScale by animateFloatAsState( - targetValue = if (isSelected) 1f else 0f, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium, - ), - label = "checkScale", - ) - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(rowBg) - .border(1.dp, Accent.copy(alpha = 0.4f * borderAlpha), RoundedCornerShape(8.dp)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - scope.launch { - SteamService.setPersonaState(state) - statusExpanded = false - } - }.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box(Modifier.size(10.dp).background(color, CircleShape)) - Text(label, color = TextPrimary, style = MaterialTheme.typography.bodyMedium) - Spacer(Modifier.weight(1f)) - Icon( - Icons.Outlined.Check, - contentDescription = null, - tint = Accent, - modifier = - Modifier - .size(16.dp) - .graphicsLayer { - scaleX = checkScale - scaleY = checkScale - alpha = checkScale - }, - ) - } - } - } - } - } - } - - Spacer(Modifier.height(20.dp)) - HorizontalDivider(color = TextSecondary.copy(alpha = 0.15f)) - Spacer(Modifier.height(20.dp)) // ── Layouts ── Text( diff --git a/app/src/main/cpp/wn-steam-client/rust/src/chat_image.rs b/app/src/main/cpp/wn-steam-client/rust/src/chat_image.rs new file mode 100644 index 000000000..8ecd155a4 --- /dev/null +++ b/app/src/main/cpp/wn-steam-client/rust/src/chat_image.rs @@ -0,0 +1,239 @@ +use sha1::{Digest, Sha1}; +use std::time::Duration; + +const COMMUNITY: &str = "https://steamcommunity.com"; + +fn sha1_hex(bytes: &[u8]) -> String { + let mut hasher = Sha1::new(); + hasher.update(bytes); + hasher + .finalize() + .iter() + .map(|b| format!("{:02x}", b)) + .collect() +} + +fn random_sessionid() -> String { + let mut bytes = [0u8; 12]; + rand::Rng::fill(&mut rand::thread_rng(), &mut bytes[..]); + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +/// Best-effort image dimensions for PNG/JPEG/GIF without an image crate. +fn image_dimensions(bytes: &[u8]) -> (u32, u32) { + if bytes.len() > 24 && &bytes[0..8] == b"\x89PNG\r\n\x1a\n" { + let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); + let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); + return (w, h); + } + if bytes.len() > 10 && (&bytes[0..6] == b"GIF89a" || &bytes[0..6] == b"GIF87a") { + let w = u16::from_le_bytes([bytes[6], bytes[7]]) as u32; + let h = u16::from_le_bytes([bytes[8], bytes[9]]) as u32; + return (w, h); + } + if bytes.len() > 4 && bytes[0] == 0xFF && bytes[1] == 0xD8 { + let mut i = 2usize; + while i + 9 < bytes.len() { + if bytes[i] != 0xFF { + i += 1; + continue; + } + let marker = bytes[i + 1]; + if (0xC0..=0xCF).contains(&marker) + && marker != 0xC4 + && marker != 0xC8 + && marker != 0xCC + { + let h = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32; + let w = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32; + return (w, h); + } + let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; + if len < 2 { + break; + } + i += 2 + len; + } + } + (0, 0) +} + +fn content_type(bytes: &[u8]) -> &'static str { + if bytes.len() > 8 && &bytes[0..8] == b"\x89PNG\r\n\x1a\n" { + "image/png" + } else if bytes.len() > 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 { + "image/jpeg" + } else if bytes.len() > 6 && (&bytes[0..6] == b"GIF89a" || &bytes[0..6] == b"GIF87a") { + "image/gif" + } else if bytes.len() > 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + "image/webp" + } else { + "image/png" + } +} + +fn build_client(ca_bundle_path: &str) -> Result { + let mut builder = reqwest::blocking::Client::builder() + .user_agent("Mozilla/5.0") + .connect_timeout(Duration::from_secs(15)); + if !ca_bundle_path.is_empty() { + if let Ok(pem) = std::fs::read(ca_bundle_path) { + if let Ok(certs) = reqwest::Certificate::from_pem_bundle(&pem) { + for cert in certs { + builder = builder.add_root_certificate(cert); + } + } + } + } + builder + .build() + .map_err(|err| format!("http client: {err}")) +} + +fn json_get_str(value: &serde_json::Value, key: &str) -> String { + value + .get(key) + .and_then(|v| { + if v.is_string() { + v.as_str().map(|s| s.to_string()) + } else if v.is_number() { + Some(v.to_string()) + } else { + None + } + }) + .unwrap_or_default() +} + +/// Uploads an image to Steam's chat UGC and returns the resulting image URL. +pub fn upload( + ca_bundle_path: &str, + self_steamid: u64, + friend_steamid: u64, + access_token: &str, + image: &[u8], + file_name: &str, +) -> Result { + if image.is_empty() { + return Err("image is empty".into()); + } + let client = build_client(ca_bundle_path)?; + let sessionid = random_sessionid(); + let cookie = format!( + "sessionid={sessionid}; steamLoginSecure={self_steamid}%7C%7C{access_token}" + ); + let sha = sha1_hex(image); + let (width, height) = image_dimensions(image); + let size = image.len().to_string(); + let width_s = width.to_string(); + let height_s = height.to_string(); + + let begin = client + .post(format!("{COMMUNITY}/chat/beginfileupload/?l=english")) + .header("Cookie", cookie.as_str()) + .header("Referer", format!("{COMMUNITY}/chat/")) + .header("Origin", COMMUNITY) + .form(&[ + ("sessionid", sessionid.as_str()), + ("l", "english"), + ("file_size", size.as_str()), + ("file_name", file_name), + ("file_sha", sha.as_str()), + ("file_image_width", width_s.as_str()), + ("file_image_height", height_s.as_str()), + ("file_type", content_type(image)), + ]) + .timeout(Duration::from_secs(30)) + .send() + .map_err(|err| format!("begin send: {err}"))?; + let begin_status = begin.status().as_u16(); + let begin_body = begin.text().map_err(|err| format!("begin body: {err}"))?; + if begin_status != 200 { + return Err(format!("begin http {begin_status}: {begin_body}")); + } + let begin_json: serde_json::Value = + serde_json::from_str(&begin_body).map_err(|err| format!("begin json: {err}"))?; + let payload = begin_json.get("result").unwrap_or(&begin_json); + let ugcid = json_get_str(payload, "ugcid"); + let hmac = json_get_str(&begin_json, "hmac"); + let timestamp = { + let top = json_get_str(&begin_json, "timestamp"); + if top.is_empty() { json_get_str(payload, "timestamp") } else { top } + }; + let url_host = json_get_str(payload, "url_host"); + let url_path = json_get_str(payload, "url_path"); + let use_https = payload + .get("use_https") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + if ugcid.is_empty() || url_host.is_empty() { + return Err(format!("begin missing ugcid/url_host: {begin_body}")); + } + + let scheme = if use_https { "https" } else { "http" }; + let put_url = format!("{scheme}://{url_host}{url_path}"); + let mut put = client + .put(&put_url) + .header("Content-Type", content_type(image)) + .body(image.to_vec()) + .timeout(Duration::from_secs(45)); + if let Some(headers) = payload.get("request_headers").and_then(|v| v.as_array()) { + for h in headers { + let name = json_get_str(h, "name"); + let value = json_get_str(h, "value"); + if !name.is_empty() { + put = put.header(name, value); + } + } + } + let put_resp = put.send().map_err(|err| format!("ugc put: {err}"))?; + let put_status = put_resp.status().as_u16(); + if !(200..300).contains(&put_status) { + return Err(format!("ugc put http {put_status}")); + } + + let commit = client + .post(format!("{COMMUNITY}/chat/commitfileupload/")) + .header("Cookie", cookie.as_str()) + .header("Referer", format!("{COMMUNITY}/chat/")) + .header("Origin", COMMUNITY) + .form(&[ + ("sessionid", sessionid.as_str()), + ("l", "english"), + ("file_name", file_name), + ("file_sha", sha.as_str()), + ("file_size", size.as_str()), + ("file_image_width", width_s.as_str()), + ("file_image_height", height_s.as_str()), + ("file_type", content_type(image)), + ("success", "1"), + ("ugcid", ugcid.as_str()), + ("timestamp", timestamp.as_str()), + ("hmac", hmac.as_str()), + ("friend_steamid", &friend_steamid.to_string()), + ("spoiler", "0"), + ]) + .timeout(Duration::from_secs(30)) + .send() + .map_err(|err| format!("commit send: {err}"))?; + let commit_status = commit.status().as_u16(); + let commit_body = commit.text().map_err(|err| format!("commit body: {err}"))?; + if commit_status != 200 { + return Err(format!("commit http {commit_status}: {commit_body}")); + } + let commit_json: serde_json::Value = + serde_json::from_str(&commit_body).map_err(|err| format!("commit json: {err}"))?; + let details = commit_json + .get("result") + .and_then(|r| r.get("details")) + .ok_or_else(|| format!("commit no details: {commit_body}"))?; + let file_sha = json_get_str(details, "file_sha"); + let sha_upper = if file_sha.is_empty() { + sha.to_uppercase() + } else { + file_sha.to_uppercase() + }; + Ok(format!( + "https://images.steamusercontent.com/ugc/{ugcid}/{sha_upper}/" + )) +} diff --git a/app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs b/app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs index 7d88e5658..b4e8d3fe5 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs @@ -73,6 +73,8 @@ pub struct FriendPersonaSnapshot { pub game_played_app_id: u32, pub avatar_hash: Vec, pub rich_presence: Vec<(String, String)>, + pub game_name: String, + pub gameid: u64, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -145,11 +147,21 @@ pub struct CMClientCore { friends: Mutex>, self_persona: Mutex>, friend_personas: Mutex>, + incoming_messages: Mutex>, library: WnLibraryStore, tickets: WnTicketCache, outbound_wires: Mutex>>, } +#[derive(Clone, Debug, Default)] +pub struct IncomingFriendMessage { + pub friend_id: u64, + pub from_self: bool, + pub message: String, + pub timestamp: u32, + pub ordinal: i32, +} + impl Default for CMClientCore { fn default() -> Self { Self { @@ -163,6 +175,7 @@ impl Default for CMClientCore { friends: Mutex::new(HashMap::new()), self_persona: Mutex::new(None), friend_personas: Mutex::new(HashMap::new()), + incoming_messages: Mutex::new(Vec::new()), library: WnLibraryStore::default(), tickets: WnTicketCache::default(), outbound_wires: Mutex::new(Vec::new()), @@ -333,6 +346,9 @@ impl CMClientCore { account_name, client_supplied_steam_id, machine_id: b"WN-Steam-Client".to_vec(), + protocol_version: 65580, + client_os_type: 16, + supports_rate_limit_response: true, ..Default::default() }; if let Some(key) = generate_session_key() { @@ -452,12 +468,66 @@ impl CMClientCore { }) } + pub fn build_request_friend_persona_states( + &self, + job_id: u64, + ) -> Option { + self.build_authed_service_call("Chat.RequestFriendPersonaStates#1", job_id, Vec::new()) + } + + pub fn build_send_friend_message( + &self, + steamid: u64, + message: &str, + contains_bbcode: bool, + job_id: u64, + ) -> Option { + self.build_authed_service_call( + "FriendMessages.SendMessage#1", + job_id, + crate::pb::cfriendmessages::CFriendMessagesSendMessageRequest { + steamid, + chat_entry_type: crate::pb::cfriendmessages::CHAT_ENTRY_TYPE_TEXT, + message: message.to_string(), + contains_bbcode, + echo_to_sender: true, + low_priority: false, + } + .serialize(), + ) + } + + pub fn build_get_recent_messages( + &self, + friend_id: u64, + count: u32, + job_id: u64, + ) -> Option { + let self_id = self.steam_id(); + if self_id == 0 || friend_id == 0 { + return None; + } + self.build_authed_service_call( + "FriendMessages.GetRecentMessages#1", + job_id, + crate::pb::cfriendmessages::CFriendMessagesGetRecentMessagesRequest { + steamid1: self_id, + steamid2: friend_id, + count, + most_recent_conversation: false, + } + .serialize(), + ) + } + pub fn build_set_persona_state(&self, persona_state: u32) -> Option { self.build_outbound_proto_message( EMsg::CLIENT_CHANGE_STATUS, CMsgClientChangeStatus { persona_state, player_name: String::new(), + persona_set_by_user: true, + need_persona_response: true, } .serialize(), 0, @@ -474,6 +544,8 @@ impl CMClientCore { CMsgClientChangeStatus { persona_state: persona_state_keep_current, player_name: name.into(), + persona_set_by_user: true, + need_persona_response: false, } .serialize(), 0, @@ -1342,15 +1414,50 @@ impl CMClientCore { .expect("friend personas poisoned"); for friend in msg.friends { if friend.friendid == self_id { - *self_persona = Some(friend); + match self_persona.as_mut() { + Some(existing) => { + if !friend.player_name.is_empty() { + existing.player_name = friend.player_name; + } + if friend.has_persona_state { + existing.persona_state = friend.persona_state; + } + if friend.has_game { + existing.game_played_app_id = friend.game_played_app_id; + } + if !friend.game_name.is_empty() { + existing.game_name = friend.game_name; + } + if friend.gameid != 0 { + existing.gameid = friend.gameid; + } + if !friend.avatar_hash.is_empty() { + existing.avatar_hash = friend.avatar_hash; + } + if !friend.rich_presence.is_empty() { + existing.rich_presence = friend.rich_presence; + } + } + None => *self_persona = Some(friend), + } } else { let slot = friend_personas.entry(friend.friendid).or_default(); slot.sid = friend.friendid; if !friend.player_name.is_empty() { slot.player_name = friend.player_name; } - slot.persona_state = friend.persona_state; - slot.game_played_app_id = friend.game_played_app_id; + if friend.has_persona_state { + slot.persona_state = friend.persona_state; + } + if friend.has_game { + slot.game_played_app_id = friend.game_played_app_id; + } + if !friend.game_name.is_empty() { + slot.game_name = friend.game_name; + } + if friend.gameid != 0 { + slot.gameid = friend.gameid; + } if !friend.avatar_hash.is_empty() { slot.avatar_hash = friend.avatar_hash; } @@ -1376,6 +1483,30 @@ impl CMClientCore { | EMsg::CLIENT_MMS_LOBBY_CHAT_MSG | EMsg::CLIENT_MMS_USER_JOINED_LOBBY | EMsg::CLIENT_MMS_USER_LEFT_LOBBY => InboundAction::LobbyPush, + EMsg::SERVICE_METHOD | EMsg::SERVICE_METHOD_SEND_TO_CLIENT => { + if header + .target_job_name + .starts_with("FriendMessagesClient.IncomingMessage") + { + if let Some(note) = + crate::pb::cfriendmessages::CFriendMessagesIncomingMessageNotification::deserialize(body) + { + if note.chat_entry_type + == crate::pb::cfriendmessages::CHAT_ENTRY_TYPE_TEXT + && !note.message.is_empty() + { + self.push_incoming_message(IncomingFriendMessage { + friend_id: note.steamid_friend, + from_self: note.local_echo, + message: note.message, + timestamp: note.rtime32_server_timestamp, + ordinal: note.ordinal, + }); + } + } + } + InboundAction::ClientMessage + } _ => InboundAction::ClientMessage, } } @@ -1412,6 +1543,22 @@ impl CMClientCore { .cloned() .collect() } + + pub fn push_incoming_message(&self, message: IncomingFriendMessage) { + self.incoming_messages + .lock() + .expect("incoming messages poisoned") + .push(message); + } + + pub fn drain_incoming_messages(&self) -> Vec { + std::mem::take( + &mut *self + .incoming_messages + .lock() + .expect("incoming messages poisoned"), + ) + } } fn parse_account_info(body: &[u8]) -> Option { @@ -1845,7 +1992,9 @@ mod tests { persona.body, CMsgClientChangeStatus { persona_state: 1, - player_name: "Ada".into() + player_name: "Ada".into(), + persona_set_by_user: true, + need_persona_response: false, } .serialize() ); diff --git a/app/src/main/cpp/wn-steam-client/rust/src/cm_runtime.rs b/app/src/main/cpp/wn-steam-client/rust/src/cm_runtime.rs index 7ab7ba042..25ae43617 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/cm_runtime.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/cm_runtime.rs @@ -171,6 +171,14 @@ impl CMClientRuntime { } InboundAction::LogonOk => { self.start_heartbeat_from_logon(body); + self.core + .enqueue_proto_message(self.core.build_set_persona_state(1)); + self.core + .enqueue_proto_message(self.core.build_request_user_persona()); + let job_id = self.jobs.next_job_id(); + self.core + .enqueue_service_call(self.core.build_request_friend_persona_states(job_id)); + self.flush_outbound(); cm_bridge::global_bridge() .observers() .dispatch_logon_state(true); diff --git a/app/src/main/cpp/wn-steam-client/rust/src/emsg.rs b/app/src/main/cpp/wn-steam-client/rust/src/emsg.rs index c0b2c8d71..62e5c5c90 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/emsg.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/emsg.rs @@ -41,6 +41,7 @@ impl EMsg { pub const CLIENT_GET_USER_STATS: Self = Self(818); pub const CLIENT_GET_USER_STATS_RESPONSE: Self = Self(819); pub const CLIENT_STORE_USER_STATS_2: Self = Self(5466); + pub const SERVICE_METHOD: Self = Self(146); pub const SERVICE_METHOD_CALL_FROM_CLIENT: Self = Self(151); pub const SERVICE_METHOD_RESPONSE: Self = Self(147); pub const SERVICE_METHOD_SEND_TO_CLIENT: Self = Self(152); diff --git a/app/src/main/cpp/wn-steam-client/rust/src/jni.rs b/app/src/main/cpp/wn-steam-client/rust/src/jni.rs index 65969682b..4b98150f7 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/jni.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/jni.rs @@ -1941,6 +1941,14 @@ pub extern "system" fn Java_com_winlator_cmod_feature_stores_steam_wnsteam_WnSte "state": persona.persona_state, "app": persona.game_played_app_id, "avatarHash": crate::cdn_client::hex_encode(&persona.avatar_hash), + "gameName": persona.game_name, + "gameId": persona.gameid as i64, + "connect": persona + .rich_presence + .iter() + .find(|(k, _)| k == "connect") + .map(|(_, v)| v.as_str()) + .unwrap_or(""), })) .collect::>()) .to_string(); @@ -1971,6 +1979,188 @@ pub extern "system" fn Java_com_winlator_cmod_feature_stores_steam_wnsteam_WnSte new_string_or_null(&mut env, &value) } +#[no_mangle] +pub extern "system" fn Java_com_winlator_cmod_feature_stores_steam_wnsteam_WnSteamSession_nativeSendFriendMessage( + mut env: JNIEnv, + _class: JClass, + handle: jlong, + steam_id: jlong, + message: JString, +) -> jstring { + let Some(handle) = (unsafe { from_session_handle_mut(handle) }) else { + return ptr::null_mut(); + }; + let Some(runtime) = handle.connected_runtime() else { + return ptr::null_mut(); + }; + let Some(message) = jstring_to_string(&mut env, &message) else { + return ptr::null_mut(); + }; + if message.is_empty() { + return ptr::null_mut(); + } + let contains_bbcode = message.contains("[img]"); + let Some(body) = + request_authed_service_body(&runtime, Duration::from_secs(15), |core, job_id| { + core.build_send_friend_message(steam_id as u64, &message, contains_bbcode, job_id) + }) + else { + return ptr::null_mut(); + }; + let Some(response) = + crate::pb::cfriendmessages::CFriendMessagesSendMessageResponse::deserialize(&body) + else { + return ptr::null_mut(); + }; + let value = json!({ + "serverTimestamp": response.server_timestamp, + "ordinal": response.ordinal, + }) + .to_string(); + new_string_or_null(&mut env, &value) +} + +#[no_mangle] +pub extern "system" fn Java_com_winlator_cmod_feature_stores_steam_wnsteam_WnSteamSession_nativeGetRecentMessages( + mut env: JNIEnv, + _class: JClass, + handle: jlong, + steam_id: jlong, + count: jint, +) -> jstring { + let Some(handle) = (unsafe { from_session_handle_mut(handle) }) else { + return new_string_or_null(&mut env, "[]"); + }; + let Some(runtime) = handle.connected_runtime() else { + return new_string_or_null(&mut env, "[]"); + }; + let self_accountid = (handle.core.steam_id() & 0xFFFF_FFFF) as u32; + let count = count.clamp(1, 200) as u32; + let Some(body) = + request_authed_service_body(&runtime, Duration::from_secs(15), |core, job_id| { + core.build_get_recent_messages(steam_id as u64, count, job_id) + }) + else { + return new_string_or_null(&mut env, "[]"); + }; + let Some(response) = + crate::pb::cfriendmessages::CFriendMessagesGetRecentMessagesResponse::deserialize(&body) + else { + return new_string_or_null(&mut env, "[]"); + }; + let value = json!(response + .messages + .iter() + .map(|m| json!({ + "fromSelf": m.accountid == self_accountid, + "message": m.message, + "timestamp": m.timestamp, + "ordinal": m.ordinal, + })) + .collect::>()) + .to_string(); + new_string_or_null(&mut env, &value) +} + +#[no_mangle] +pub extern "system" fn Java_com_winlator_cmod_feature_stores_steam_wnsteam_WnSteamSession_nativeDrainFriendMessages( + mut env: JNIEnv, + _class: JClass, + handle: jlong, +) -> jstring { + let Some(handle) = (unsafe { from_session_handle_mut(handle) }) else { + return new_string_or_null(&mut env, "[]"); + }; + let value = json!(handle + .core + .drain_incoming_messages() + .iter() + .map(|m| json!({ + "friendId": m.friend_id as i64, + "fromSelf": m.from_self, + "message": m.message, + "timestamp": m.timestamp, + "ordinal": m.ordinal, + })) + .collect::>()) + .to_string(); + new_string_or_null(&mut env, &value) +} + +#[no_mangle] +pub extern "system" fn Java_com_winlator_cmod_feature_stores_steam_wnsteam_WnSteamSession_nativeSendChatImage( + mut env: JNIEnv, + _class: JClass, + handle: jlong, + steam_id: jlong, + refresh_token: JString, + image: JByteArray, + file_name: JString, +) -> jstring { + let Some(handle) = (unsafe { from_session_handle_mut(handle) }) else { + return ptr::null_mut(); + }; + let Some(runtime) = handle.connected_runtime() else { + return ptr::null_mut(); + }; + let self_steamid = handle.core.steam_id(); + let ca_bundle_path = handle.ca_bundle_path.clone(); + let Some(refresh_token) = jstring_to_string(&mut env, &refresh_token) else { + return ptr::null_mut(); + }; + let file_name = jstring_to_string(&mut env, &file_name).unwrap_or_else(|| "image.png".into()); + let Ok(bytes) = env.convert_byte_array(image) else { + return ptr::null_mut(); + }; + if bytes.is_empty() || self_steamid == 0 || refresh_token.is_empty() { + return ptr::null_mut(); + } + + // Mint a short-lived web access token for the steamLoginSecure cookie. + let request = crate::pb::cauthentication::AccessTokenGenerateForAppRequest { + refresh_token, + steamid: self_steamid, + renewal_type: crate::pb::cauthentication::EAuthTokenRenewalType::None, + } + .serialize(); + let Some(token_body) = + request_authed_service_body(&runtime, Duration::from_secs(15), move |core, job_id| { + core.build_authed_service_call( + "Authentication.GenerateAccessTokenForApp#1", + job_id, + request, + ) + }) + else { + return ptr::null_mut(); + }; + let access_token = crate::pb::cauthentication::AccessTokenGenerateForAppResponse::deserialize( + &token_body, + ) + .map(|r| r.access_token) + .unwrap_or_default(); + if access_token.is_empty() { + android_log("WNIMG", "no web access token"); + return ptr::null_mut(); + } + + let url = match crate::chat_image::upload( + &ca_bundle_path, + self_steamid, + steam_id as u64, + &access_token, + &bytes, + &file_name, + ) { + Ok(url) => url, + Err(err) => { + android_log("WNIMG", &format!("upload failed: {err}")); + return ptr::null_mut(); + } + }; + new_string_or_null(&mut env, &url) +} + #[no_mangle] pub extern "system" fn Java_com_winlator_cmod_feature_stores_steam_wnsteam_WnSteamSession_nativeSetPersonaState( _env: JNIEnv, diff --git a/app/src/main/cpp/wn-steam-client/rust/src/lib.rs b/app/src/main/cpp/wn-steam-client/rust/src/lib.rs index 14a267153..04df26dc0 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/lib.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/lib.rs @@ -10,6 +10,7 @@ pub mod auth_session; pub mod authenticator; pub mod base64; pub mod cdn_client; +pub mod chat_image; pub mod cm_bridge; pub mod cm_client; pub mod cm_runtime; diff --git a/app/src/main/cpp/wn-steam-client/rust/src/pb/cfriendmessages.rs b/app/src/main/cpp/wn-steam-client/rust/src/pb/cfriendmessages.rs new file mode 100644 index 000000000..ef7f6a61d --- /dev/null +++ b/app/src/main/cpp/wn-steam-client/rust/src/pb/cfriendmessages.rs @@ -0,0 +1,245 @@ +use crate::proto_wire::{Reader, WireType, Writer}; + +pub const CHAT_ENTRY_TYPE_TEXT: i32 = 1; + +#[derive(Clone, Debug, Default)] +pub struct CFriendMessagesSendMessageRequest { + pub steamid: u64, + pub chat_entry_type: i32, + pub message: String, + pub contains_bbcode: bool, + pub echo_to_sender: bool, + pub low_priority: bool, +} + +impl CFriendMessagesSendMessageRequest { + pub fn serialize(&self) -> Vec { + let mut out = Vec::new(); + let mut w = Writer::new(&mut out); + w.fixed64_field(1, self.steamid); + w.int32_field(2, self.chat_entry_type); + w.string_field(3, &self.message); + if self.contains_bbcode { + w.bool_field(4, true); + } + if self.echo_to_sender { + w.bool_field(5, true); + } + if self.low_priority { + w.bool_field(6, true); + } + out + } +} + +#[derive(Clone, Debug, Default)] +pub struct CFriendMessagesSendMessageResponse { + pub modified_message: String, + pub server_timestamp: u32, + pub ordinal: i32, + pub message_without_bb_code: String, +} + +impl CFriendMessagesSendMessageResponse { + pub fn deserialize(body: &[u8]) -> Option { + let mut reader = Reader::new(body); + let mut msg = Self::default(); + while !reader.eof() { + let Some(tag) = reader.next_tag() else { + return reader.ok().then_some(msg); + }; + match (tag.field_number, tag.wire_type) { + (1, WireType::LengthDelimited) => msg.modified_message = reader.string()?, + (2, WireType::Varint) => msg.server_timestamp = reader.u32()?, + (3, WireType::Varint) => msg.ordinal = reader.i32()?, + (4, WireType::LengthDelimited) => { + msg.message_without_bb_code = reader.string()? + } + _ => { + if !reader.skip(tag.wire_type) { + return None; + } + } + } + } + Some(msg) + } +} + +#[derive(Clone, Debug, Default)] +pub struct CFriendMessagesGetRecentMessagesRequest { + pub steamid1: u64, + pub steamid2: u64, + pub count: u32, + pub most_recent_conversation: bool, +} + +impl CFriendMessagesGetRecentMessagesRequest { + pub fn serialize(&self) -> Vec { + let mut out = Vec::new(); + let mut w = Writer::new(&mut out); + w.fixed64_field(1, self.steamid1); + w.fixed64_field(2, self.steamid2); + w.uint32_field(3, self.count); + if self.most_recent_conversation { + w.bool_field(4, true); + } + out + } +} + +#[derive(Clone, Debug, Default)] +pub struct FriendMessage { + pub accountid: u32, + pub timestamp: u32, + pub message: String, + pub ordinal: i32, +} + +impl FriendMessage { + fn deserialize(body: &[u8]) -> Option { + let mut reader = Reader::new(body); + let mut msg = Self::default(); + while !reader.eof() { + let Some(tag) = reader.next_tag() else { + return reader.ok().then_some(msg); + }; + match (tag.field_number, tag.wire_type) { + (1, WireType::Varint) => msg.accountid = reader.u32()?, + (2, WireType::Varint) => msg.timestamp = reader.u32()?, + (3, WireType::LengthDelimited) => msg.message = reader.string()?, + (4, WireType::Varint) => msg.ordinal = reader.i32()?, + _ => { + if !reader.skip(tag.wire_type) { + return None; + } + } + } + } + Some(msg) + } +} + +#[derive(Clone, Debug, Default)] +pub struct CFriendMessagesGetRecentMessagesResponse { + pub messages: Vec, + pub more_available: bool, +} + +impl CFriendMessagesGetRecentMessagesResponse { + pub fn deserialize(body: &[u8]) -> Option { + let mut reader = Reader::new(body); + let mut msg = Self::default(); + while !reader.eof() { + let Some(tag) = reader.next_tag() else { + return reader.ok().then_some(msg); + }; + match (tag.field_number, tag.wire_type) { + (1, WireType::LengthDelimited) => { + msg.messages.push(FriendMessage::deserialize(reader.bytes()?)?) + } + (4, WireType::Varint) => msg.more_available = reader.u32()? != 0, + _ => { + if !reader.skip(tag.wire_type) { + return None; + } + } + } + } + Some(msg) + } +} + +#[derive(Clone, Debug, Default)] +pub struct CFriendMessagesIncomingMessageNotification { + pub steamid_friend: u64, + pub chat_entry_type: i32, + pub message: String, + pub rtime32_server_timestamp: u32, + pub ordinal: i32, + pub local_echo: bool, + pub message_no_bbcode: String, +} + +impl CFriendMessagesIncomingMessageNotification { + pub fn deserialize(body: &[u8]) -> Option { + let mut reader = Reader::new(body); + let mut msg = Self::default(); + while !reader.eof() { + let Some(tag) = reader.next_tag() else { + return reader.ok().then_some(msg); + }; + match (tag.field_number, tag.wire_type) { + (1, WireType::Fixed64) => msg.steamid_friend = reader.fixed64()?, + (2, WireType::Varint) => msg.chat_entry_type = reader.i32()?, + (4, WireType::LengthDelimited) => msg.message = reader.string()?, + (5, WireType::Fixed32) => msg.rtime32_server_timestamp = reader.fixed32()?, + (6, WireType::Varint) => msg.ordinal = reader.i32()?, + (7, WireType::Varint) => msg.local_echo = reader.u32()? != 0, + (8, WireType::LengthDelimited) => msg.message_no_bbcode = reader.string()?, + _ => { + if !reader.skip(tag.wire_type) { + return None; + } + } + } + } + Some(msg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn send_request_roundtrips_fields() { + let req = CFriendMessagesSendMessageRequest { + steamid: 76561198000000000, + chat_entry_type: CHAT_ENTRY_TYPE_TEXT, + message: "hello".into(), + echo_to_sender: true, + ..Default::default() + }; + let bytes = req.serialize(); + let mut reader = Reader::new(&bytes); + let tag = reader.next_tag().unwrap(); + assert_eq!(tag.field_number, 1); + assert_eq!(reader.fixed64().unwrap(), 76561198000000000); + } + + #[test] + fn incoming_notification_parses() { + let mut body = Vec::new(); + { + let mut w = Writer::new(&mut body); + w.fixed64_field(1, 123); + w.int32_field(2, CHAT_ENTRY_TYPE_TEXT); + w.string_field(4, "hi there"); + w.fixed32_field(5, 1700000000); + w.int32_field(6, 0); + } + let parsed = CFriendMessagesIncomingMessageNotification::deserialize(&body).unwrap(); + assert_eq!(parsed.steamid_friend, 123); + assert_eq!(parsed.message, "hi there"); + assert_eq!(parsed.rtime32_server_timestamp, 1700000000); + } + + #[test] + fn recent_message_parses_ordinal_from_field4_and_skips_reactions() { + let mut body = Vec::new(); + { + let mut w = Writer::new(&mut body); + w.uint32_field(1, 42); + w.uint32_field(2, 1700000000); + w.string_field(3, "yo"); + w.int32_field(4, 7); + w.string_field(5, "reactions-submessage"); + } + let parsed = FriendMessage::deserialize(&body).unwrap(); + assert_eq!(parsed.accountid, 42); + assert_eq!(parsed.timestamp, 1700000000); + assert_eq!(parsed.message, "yo"); + assert_eq!(parsed.ordinal, 7); + } +} diff --git a/app/src/main/cpp/wn-steam-client/rust/src/pb/cmsg_client_change_status.rs b/app/src/main/cpp/wn-steam-client/rust/src/pb/cmsg_client_change_status.rs index 634226355..2a7716375 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/pb/cmsg_client_change_status.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/pb/cmsg_client_change_status.rs @@ -4,6 +4,8 @@ use crate::proto_wire::Writer; pub struct CMsgClientChangeStatus { pub persona_state: u32, pub player_name: String, + pub persona_set_by_user: bool, + pub need_persona_response: bool, } impl CMsgClientChangeStatus { @@ -12,6 +14,12 @@ impl CMsgClientChangeStatus { let mut w = Writer::new(&mut out); w.uint32_field_force(1, self.persona_state); w.string_field(2, &self.player_name); + if self.persona_set_by_user { + w.bool_field(5, true); + } + if self.need_persona_response { + w.bool_field(7, true); + } out } } diff --git a/app/src/main/cpp/wn-steam-client/rust/src/pb/cmsg_client_persona.rs b/app/src/main/cpp/wn-steam-client/rust/src/pb/cmsg_client_persona.rs index 7b61972fc..8e3f38951 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/pb/cmsg_client_persona.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/pb/cmsg_client_persona.rs @@ -1,4 +1,4 @@ -use crate::proto_wire::{Reader, Writer}; +use crate::proto_wire::{Reader, WireType, Writer}; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct CMsgClientRequestFriendData { @@ -28,6 +28,8 @@ pub struct PersonaStateFriend { pub game_name: String, pub gameid: u64, pub rich_presence: Vec<(String, String)>, + pub has_persona_state: bool, + pub has_game: bool, } impl PersonaStateFriend { @@ -38,17 +40,23 @@ impl PersonaStateFriend { let Some(tag) = reader.next_tag() else { return reader.ok().then_some(msg); }; - match tag.field_number { - 1 => msg.friendid = reader.fixed64()?, - 2 => msg.persona_state = reader.u32()?, - 3 => msg.game_played_app_id = reader.u32()?, - 15 => msg.player_name = reader.string()?, - 25 => msg + match (tag.field_number, tag.wire_type) { + (1, WireType::Fixed64) => msg.friendid = reader.fixed64()?, + (2, WireType::Varint) => { + msg.persona_state = reader.u32()?; + msg.has_persona_state = true; + } + (3, WireType::Varint) => { + msg.game_played_app_id = reader.u32()?; + msg.has_game = true; + } + (15, WireType::LengthDelimited) => msg.player_name = reader.string()?, + (25, WireType::LengthDelimited) => msg .rich_presence .push(parse_kv_submessage(reader.bytes()?)?), - 31 => msg.avatar_hash = reader.bytes()?.to_vec(), - 55 => msg.game_name = reader.string()?, - 56 => msg.gameid = reader.fixed64()?, + (31, WireType::LengthDelimited) => msg.avatar_hash = reader.bytes()?.to_vec(), + (55, WireType::LengthDelimited) => msg.game_name = reader.string()?, + (56, WireType::Fixed64) => msg.gameid = reader.fixed64()?, _ => { if !reader.skip(tag.wire_type) { return None; @@ -83,6 +91,7 @@ fn parse_kv_submessage(body: &[u8]) -> Option<(String, String)> { #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct CMsgClientPersonaState { + pub status_flags: u32, pub friends: Vec, } @@ -95,6 +104,7 @@ impl CMsgClientPersonaState { return reader.ok().then_some(msg); }; match tag.field_number { + 1 => msg.status_flags = reader.u32()?, 2 => msg .friends .push(PersonaStateFriend::deserialize(reader.bytes()?)?), @@ -146,5 +156,29 @@ mod tests { assert_eq!(friend.rich_presence[0], ("status".into(), "Playing".into())); assert_eq!(friend.avatar_hash, [1, 2, 3]); assert_eq!(friend.game_name, "Team Fortress 2"); + assert!(friend.has_persona_state); + assert!(friend.has_game); + } + + #[test] + fn stateful_push_with_field25_as_fixed64_still_parses() { + // Live persona pushes carry field 25 as a fixed64, not the rich-presence submessage. + let mut friend = Vec::new(); + { + let mut w = Writer::new(&mut friend); + w.fixed64_field(1, 77); + w.uint32_field(2, 1); + w.fixed64_field(25, 0); + w.string_field(15, "Online Friend"); + } + let mut body = Vec::new(); + Writer::new(&mut body).submessage_field(2, &friend); + + let parsed = CMsgClientPersonaState::deserialize(&body).unwrap(); + let friend = &parsed.friends[0]; + assert_eq!(friend.friendid, 77); + assert_eq!(friend.persona_state, 1); + assert!(friend.has_persona_state); + assert_eq!(friend.player_name, "Online Friend"); } } diff --git a/app/src/main/cpp/wn-steam-client/rust/src/pb/mod.rs b/app/src/main/cpp/wn-steam-client/rust/src/pb/mod.rs index 7f791c365..27a82e1b4 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/pb/mod.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/pb/mod.rs @@ -2,6 +2,7 @@ pub mod cauthentication; pub mod ccloud; pub mod ccontentserverdirectory; pub mod cfamilygroups; +pub mod cfriendmessages; pub mod cinventory; pub mod cmsg_client_change_status; pub mod cmsg_client_friends_list; diff --git a/app/src/main/feature/library/GameSettings.kt b/app/src/main/feature/library/GameSettings.kt index 46a99dde3..166869cb9 100644 --- a/app/src/main/feature/library/GameSettings.kt +++ b/app/src/main/feature/library/GameSettings.kt @@ -443,6 +443,12 @@ private val ExtraArgPresets = listOf( "General", listOf( "-windowed", "-fullscreen", "-nointro", "-skipvideos", "-novsync", "/d3d9" ) + ), + // Steam-style launch options: KEY=VALUE before %command% become env vars; args after go to the game. + ExtraArgGroup( + "Steam (%command%)", listOf( + "%command%", "%command% -windowed", "DXVK_HUD=fps %command%", "WINEDEBUG=-all %command%" + ) ) ) diff --git a/app/src/main/feature/stores/steam/achievements/SteamAchievementsScreen.kt b/app/src/main/feature/stores/steam/achievements/SteamAchievementsScreen.kt new file mode 100644 index 000000000..3aab12b02 --- /dev/null +++ b/app/src/main/feature/stores/steam/achievements/SteamAchievementsScreen.kt @@ -0,0 +1,244 @@ +package com.winlator.cmod.feature.stores.steam.achievements + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.winlator.cmod.R +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.feature.stores.steam.statsgen.Achievement +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.text.DateFormat +import java.util.Date + +private val BgDark = Color(0xFF18181D) +private val SurfaceDark = Color(0xFF1E252E) +private val CardBorder = Color(0xFF2A2A3A) +private val Accent = Color(0xFF1A9FFF) +private val TextPrimary = Color(0xFFF0F4FF) +private val TextSecondary = Color(0xFF7A8FA8) +private val StatusOnline = Color(0xFF3FB950) + +private fun iconUrl(appId: Int, icon: String?): String? { + val raw = icon?.trim().orEmpty() + if (raw.isEmpty() || raw.contains("steam_default_icon")) return null + if (raw.startsWith("http")) return raw + return "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/$appId/$raw" +} + +private fun Achievement.title(): String = + displayName?.get("english") ?: displayName?.values?.firstOrNull() ?: name + +private fun Achievement.desc(): String = + description?.get("english") ?: description?.values?.firstOrNull() ?: "" + +@Composable +fun SteamAchievementsScreen( + appId: Int, + appName: String, + onClose: () -> Unit, +) { + val context = LocalContext.current + var loading by remember { mutableStateOf(true) } + var achievements by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(appId) { + loading = true + achievements = withContext(Dispatchers.IO) { + val dir = File(context.cacheDir, "achievements_$appId").apply { mkdirs() } + SteamService.loadAchievements(appId, dir.absolutePath) + } + loading = false + } + + val unlockedCount = achievements.count { it.unlocked == true } + val total = achievements.size + val ordered = achievements.sortedWith( + compareByDescending { it.unlocked == true } + .thenByDescending { it.unlockTimestamp ?: 0 }, + ) + + Surface(color = BgDark, modifier = Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize().statusBarsPadding()) { + Column(Modifier.fillMaxWidth().background(SurfaceDark).padding(bottom = 12.dp)) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onClose) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.steam_common_back), tint = TextPrimary) + } + Spacer(Modifier.width(4.dp)) + Column(Modifier.weight(1f)) { + Text( + appName, + color = TextPrimary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + stringResource(R.string.steam_achievements_title), + color = TextSecondary, + style = MaterialTheme.typography.labelMedium, + ) + } + if (total > 0) { + Text( + "$unlockedCount / $total", + color = Accent, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(end = 14.dp), + ) + } + } + if (total > 0) { + LinearProgressIndicator( + progress = { unlockedCount.toFloat() / total }, + color = StatusOnline, + trackColor = BgDark, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(6.dp).clip(RoundedCornerShape(3.dp)), + ) + } + } + + Box(Modifier.weight(1f).fillMaxWidth()) { + when { + loading -> CircularProgressIndicator( + color = Accent, + modifier = Modifier.size(30.dp).align(Alignment.Center), + ) + achievements.isEmpty() -> Text( + stringResource(R.string.steam_achievements_empty), + color = TextSecondary, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.align(Alignment.Center).padding(24.dp), + ) + else -> LazyColumn( + Modifier.fillMaxSize().padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp), + ) { + items(ordered) { ach -> AchievementRow(appId, ach) } + } + } + } + } + } +} + +@Composable +private fun AchievementRow(appId: Int, ach: Achievement) { + val unlocked = ach.unlocked == true + val hiddenLocked = ach.hidden == 1 && !unlocked + val url = iconUrl(appId, if (unlocked) ach.icon ?: ach.iconGray else ach.iconGray ?: ach.icon) + Surface( + shape = RoundedCornerShape(12.dp), + color = if (unlocked) Accent.copy(alpha = 0.06f) else SurfaceDark.copy(alpha = 0.5f), + border = androidx.compose.foundation.BorderStroke(1.dp, if (unlocked) Accent.copy(alpha = 0.25f) else CardBorder), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.size(52.dp).clip(RoundedCornerShape(8.dp)).background(BgDark), + contentAlignment = Alignment.Center, + ) { + if (url != null) { + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(52.dp).clip(RoundedCornerShape(8.dp)), + alpha = if (unlocked) 1f else 0.55f, + ) + } else { + Icon( + Icons.Outlined.Lock, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(24.dp), + ) + } + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + if (hiddenLocked) stringResource(R.string.steam_achievements_hidden) else ach.title(), + color = if (unlocked) TextPrimary else TextSecondary, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val sub = if (hiddenLocked) stringResource(R.string.steam_achievements_hidden_desc) else ach.desc() + if (sub.isNotBlank()) { + Text( + sub, + color = TextSecondary, + style = MaterialTheme.typography.labelMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (unlocked) { + val ts = ach.unlockTimestamp ?: 0 + val when_ = if (ts > 0) { + stringResource(R.string.steam_achievements_unlocked_on, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(ts.toLong() * 1000L))) + } else stringResource(R.string.steam_achievements_unlocked) + Text( + when_, + color = StatusOnline, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + } + } +} diff --git a/app/src/main/feature/stores/steam/chat/ChatImagePickerActivity.kt b/app/src/main/feature/stores/steam/chat/ChatImagePickerActivity.kt new file mode 100644 index 000000000..f40e43369 --- /dev/null +++ b/app/src/main/feature/stores/steam/chat/ChatImagePickerActivity.kt @@ -0,0 +1,48 @@ +package com.winlator.cmod.feature.stores.steam.chat + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import com.winlator.cmod.feature.stores.steam.service.SteamService + +class ChatImagePickerActivity : ComponentActivity() { + private val pick = + registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + val friendId = intent.getLongExtra(EXTRA_FRIEND_ID, 0L) + if (uri != null && friendId != 0L) { + val cr = contentResolver + val mime = cr.getType(uri) ?: "image/png" + val ext = when { + mime.contains("jpeg") || mime.contains("jpg") -> "jpeg" + mime.contains("gif") -> "gif" + mime.contains("webp") -> "webp" + else -> "png" + } + Thread { + runCatching { + val bytes = cr.openInputStream(uri)?.use { it.readBytes() } + if (bytes != null && bytes.isNotEmpty()) { + SteamService.instance?.sendChatImageAsync(friendId, bytes, "image.$ext") + } + } + }.start() + } + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (savedInstanceState != null) { + finish() + return + } + runCatching { + pick.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }.onFailure { finish() } + } + + companion object { + const val EXTRA_FRIEND_ID = "friendId" + } +} diff --git a/app/src/main/feature/stores/steam/chat/ChatOverlayService.kt b/app/src/main/feature/stores/steam/chat/ChatOverlayService.kt new file mode 100644 index 000000000..0652f058a --- /dev/null +++ b/app/src/main/feature/stores/steam/chat/ChatOverlayService.kt @@ -0,0 +1,867 @@ +package com.winlator.cmod.feature.stores.steam.chat + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.PixelFormat +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.provider.Settings +import android.view.Gravity +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Chat +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.winlator.cmod.R +import com.winlator.cmod.feature.stores.steam.data.SteamChatMessage +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.shared.theme.WinNativeTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlin.math.hypot + +private val IMG_BBCODE = Regex("\\[img\\](.*?)\\[/img\\]", RegexOption.IGNORE_CASE) +private val IMG_SRC = Regex("\\[img\\s+src=[\"']?(.*?)[\"']?\\s*\\]", RegexOption.IGNORE_CASE) +private val STEAM_IMG_URL = Regex("https://images\\.steamusercontent\\.com/ugc/\\S+") +private val BARE_IMG_URL = Regex("https?://\\S+\\.(?:png|jpe?g|gif|webp)", RegexOption.IGNORE_CASE) + +private fun overlayImageUrlOf(text: String): String? { + val t = text.trim() + return IMG_BBCODE.find(t)?.groupValues?.getOrNull(1) + ?: IMG_SRC.find(t)?.groupValues?.getOrNull(1) + ?: STEAM_IMG_URL.find(t)?.value + ?: BARE_IMG_URL.find(t)?.value +} + +@Composable +private fun overlayImage(data: Any?): ImageRequest { + val ctx = LocalContext.current + return remember(data) { ImageRequest.Builder(ctx).data(data).allowHardware(false).build() } +} + +private val BgDark = Color(0xFF18181D) +private val SurfaceDark = Color(0xFF1E252E) +private val CardBorder = Color(0xFF2A2A3A) +private val Accent = Color(0xFF1A9FFF) +private val TextPrimary = Color(0xFFF0F4FF) +private val TextSecondary = Color(0xFF7A8FA8) +private val Danger = Color(0xFFFF5A5A) + +/** Facebook-Messenger-style floating chat heads rendered as a system overlay so they work over games. */ +class ChatOverlayService : Service() { + private lateinit var windowManager: WindowManager + private val lifecycleOwner = OverlayLifecycleOwner() + + private var bubbleView: View? = null + private var panelView: ComposeView? = null + private var targetView: ComposeView? = null + + private val bubbleParams by lazy { buildBubbleParams() } + private val panelParams by lazy { buildPanelParams() } + private val targetParams by lazy { buildTargetParams() } + + private val headFriendId = mutableLongStateOf(0L) + private val expanded = mutableStateOf(false) + private val conversationId = mutableLongStateOf(0L) + private val dragging = mutableStateOf(false) + private val bubbleDimmed = mutableStateOf(false) + + private val bubbleX = mutableIntStateOf(0) + private val bubbleY = mutableIntStateOf(0) + + private val uiScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + private var idleLoopJob: Job? = null + @Volatile private var lastInteractionMs = 0L + private val touchHandler = Handler(Looper.getMainLooper()) + + private val density by lazy { resources.displayMetrics.density } + // Collapsed chat-head footprint; the bubble window is pinned to this exact size. + private val bubbleSizePx by lazy { (56 * density).toInt() } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + windowManager = getSystemService(WINDOW_SERVICE) as WindowManager + lifecycleOwner.create() + showBubble() + startIdleLoop() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!Settings.canDrawOverlays(this)) { + stopSelf() + return START_NOT_STICKY + } + val fid = intent?.getLongExtra(EXTRA_FRIEND_ID, 0L) ?: 0L + if (fid != 0L) { + headFriendId.longValue = fid + if (!expanded.value) showBubble() + } + pokeAutoHide() + startIdleLoop() + return START_NOT_STICKY + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + touchHandler.postDelayed({ + if (expanded.value) collapse() + snapToEdge() + applyBubblePosition() + }, 150L) + } + + override fun onDestroy() { + super.onDestroy() + idleLoopJob?.cancel() + uiScope.cancel() + touchHandler.removeCallbacksAndMessages(null) + removeView(panelView); panelView = null + removeView(targetView); targetView = null + removeView(bubbleView); bubbleView = null + lifecycleOwner.destroy() + } + + private fun pokeAutoHide() { + lastInteractionMs = System.currentTimeMillis() + if (bubbleDimmed.value) bubbleDimmed.value = false + } + + private fun startIdleLoop() { + if (idleLoopJob?.isActive == true) return + idleLoopJob = uiScope.launch { + while (true) { + delay(1000L) + val dim = PrefManager.chatHeadsAutoHide && + bubbleView?.parent != null && + System.currentTimeMillis() - lastInteractionMs >= 5000L + if (bubbleDimmed.value != dim) bubbleDimmed.value = dim + } + } + } + + private fun openConversation(friendId: Long) { + conversationId.longValue = friendId + if (friendId != 0L) headFriendId.longValue = friendId + } + + private fun prepare(view: View) { + view.setViewTreeLifecycleOwner(lifecycleOwner) + view.setViewTreeViewModelStoreOwner(lifecycleOwner) + view.setViewTreeSavedStateRegistryOwner(lifecycleOwner) + } + + private fun removeView(view: View?) { + if (view?.parent != null) runCatching { windowManager.removeView(view) } + } + + private fun showBubble() { + if (bubbleView?.parent != null) return + if (bubbleX.intValue == 0 && bubbleY.intValue == 0) { + val m = resources.displayMetrics + bubbleX.intValue = m.widthPixels - bubbleSizePx - (12 * density).toInt() + bubbleY.intValue = (140 * density).toInt() + } + bubbleParams.x = bubbleX.intValue + bubbleParams.y = bubbleY.intValue + val composeView = ComposeView(this).apply { + setContent { + WinNativeTheme { + val svc = remember { SteamService.instance } + val friends by svc?.friendsList?.collectAsState() ?: remember { mutableStateOf(emptyList()) } + val unread by svc?.unreadCounts?.collectAsState() ?: remember { mutableStateOf(emptyMap()) } + val head = friends.firstOrNull { it.steamId == headFriendId.longValue } + val alpha by animateFloatAsState(if (bubbleDimmed.value) 0.2f else 1f, label = "bubbleAlpha") + Box(Modifier.alpha(alpha)) { + BubbleContent(head, unread.values.sum()) + } + } + } + } + val container = object : FrameLayout(this) { + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean = true + } + container.addView(composeView) + prepare(container) + attachBubbleTouch(container) + bubbleView = container + runCatching { windowManager.addView(container, bubbleParams) } + pokeAutoHide() + } + + private fun applyBubblePosition() { + bubbleParams.x = bubbleX.intValue + bubbleParams.y = bubbleY.intValue + bubbleView?.let { runCatching { windowManager.updateViewLayout(it, bubbleParams) } } + } + + private fun hideBubble() { + touchHandler.removeCallbacksAndMessages(null) + removeView(bubbleView) + bubbleView = null + } + + private fun showPanel() { + if (panelView?.parent != null) return + val view = ComposeView(this).apply { + setContent { + WinNativeTheme { + PanelContent() + } + } + } + prepare(view) + panelView = view + runCatching { windowManager.addView(view, panelParams) } + } + + private fun hidePanel() { + removeView(panelView) + panelView = null + } + + private fun showTarget() { + if (targetView?.parent != null) return + val view = ComposeView(this).apply { + setContent { WinNativeTheme { DismissTarget(dragging.value) } } + } + prepare(view) + targetView = view + runCatching { windowManager.addView(view, targetParams) } + } + + private fun hideTarget() { + removeView(targetView) + targetView = null + } + + private fun expand() { + if (expanded.value) return + conversationId.longValue = headFriendId.longValue + expanded.value = true + showPanel() + hideBubble() + } + + private fun collapse() { + if (!expanded.value) return + if (conversationId.longValue == 0L) headFriendId.longValue = 0L + expanded.value = false + hidePanel() + showBubble() + } + + private fun attachBubbleTouch(view: View) { + var startX = 0 + var startY = 0 + var downRawX = 0f + var downRawY = 0f + var dragAllowed = false + var gestureDone = false + val tapSlop = 14 * density + val longPress = Runnable { + if (bubbleView?.parent == null || gestureDone) return@Runnable + dragAllowed = true + dragging.value = true + showTarget() + runCatching { view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) } + } + view.setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + startX = bubbleX.intValue + startY = bubbleY.intValue + downRawX = event.rawX + downRawY = event.rawY + dragAllowed = false + gestureDone = false + pokeAutoHide() + touchHandler.postDelayed(longPress, 400L) + true + } + MotionEvent.ACTION_MOVE -> { + if (dragAllowed) { + bubbleX.intValue = startX + (event.rawX - downRawX).toInt() + bubbleY.intValue = startY + (event.rawY - downRawY).toInt() + applyBubblePosition() + } + true + } + MotionEvent.ACTION_UP -> { + gestureDone = true + touchHandler.removeCallbacks(longPress) + if (dragAllowed) { + dragging.value = false + hideTarget() + if (overDismissTarget()) stopSelf() else { snapToEdge(); applyBubblePosition() } + } else if (hypot(event.rawX - downRawX, event.rawY - downRawY) <= tapSlop) { + view.performClick() + expand() + } + true + } + MotionEvent.ACTION_CANCEL -> { + gestureDone = true + touchHandler.removeCallbacks(longPress) + if (dragAllowed) { + dragging.value = false + hideTarget() + snapToEdge() + applyBubblePosition() + } + true + } + else -> false + } + } + } + + private fun overDismissTarget(): Boolean { + val m = resources.displayMetrics + val bubbleCx = bubbleX.intValue + bubbleSizePx / 2f + val bubbleCy = bubbleY.intValue + bubbleSizePx / 2f + val targetCx = m.widthPixels / 2f + val targetCy = m.heightPixels - (96 * density) + return hypot(bubbleCx - targetCx, bubbleCy - targetCy) < (80 * density) + } + + private fun snapToEdge() { + val m = resources.displayMetrics + val w = m.widthPixels + val h = m.heightPixels + val maxX = w - bubbleSizePx + val maxY = h - bubbleSizePx + val x = bubbleX.intValue.coerceIn(0, maxX) + val y = bubbleY.intValue.coerceIn(0, maxY) + val cx = x + bubbleSizePx / 2 + val cy = y + bubbleSizePx / 2 + when (minOf(cx, w - cx, cy, h - cy)) { + cx -> { bubbleX.intValue = 0; bubbleY.intValue = y } + w - cx -> { bubbleX.intValue = maxX; bubbleY.intValue = y } + cy -> { bubbleX.intValue = x; bubbleY.intValue = 0 } + else -> { bubbleX.intValue = x; bubbleY.intValue = maxY } + } + } + + @Composable + private fun BubbleContent(head: SteamFriendEntry?, unread: Int) { + Box(contentAlignment = Alignment.TopEnd) { + Surface( + shape = CircleShape, + color = SurfaceDark, + border = androidx.compose.foundation.BorderStroke(2.dp, Accent.copy(alpha = 0.6f)), + shadowElevation = 8.dp, + modifier = Modifier.size(56.dp), + ) { + Box(contentAlignment = Alignment.Center) { + val url = head?.avatarUrl + if (url != null) { + AsyncImage( + model = overlayImage(url), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(56.dp).clip(CircleShape), + ) + } else { + Icon(Icons.AutoMirrored.Outlined.Chat, contentDescription = null, tint = Accent, modifier = Modifier.size(26.dp)) + } + } + } + if (unread > 0) { + Box( + Modifier.size(20.dp).clip(CircleShape).background(Danger), + contentAlignment = Alignment.Center, + ) { + Text( + if (unread > 9) "9+" else unread.toString(), + color = Color.White, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + + @Composable + private fun DismissTarget(active: Boolean) { + Box( + Modifier.size(64.dp).clip(CircleShape).background(if (active) Danger else Color(0xCC000000)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Close, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp)) + } + } + + @Composable + private fun PanelContent() { + val svc = remember { SteamService.instance } + val friends by svc?.friendsList?.collectAsState() ?: remember { mutableStateOf(emptyList()) } + val unread by svc?.unreadCounts?.collectAsState() ?: remember { mutableStateOf(emptyMap()) } + val recent by svc?.recentChats?.collectAsState() ?: remember { mutableStateOf(emptyMap()) } + val convId = conversationId.longValue + val current = friends.firstOrNull { it.steamId == convId } + val head = if (convId != 0L) current else null + + val ordered = remember(friends, unread, recent) { + friends.sortedWith( + compareByDescending { (unread[it.steamId] ?: 0) > 0 } + .thenByDescending { recent[it.steamId] ?: 0L } + .thenByDescending { it.isPlayingGame } + .thenByDescending { it.isOnline } + .thenBy { it.name.lowercase() }, + ) + } + + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { visible = true } + + val screenW = resources.displayMetrics.widthPixels + val screenH = resources.displayMetrics.heightPixels + val marginPx = (8 * density).toInt() + val rightSide = bubbleX.intValue + bubbleSizePx / 2 >= screenW / 2 + val panelWpx = (screenW * 0.4f).toInt().coerceIn((280 * density).toInt(), screenW - 2 * marginPx) + val panelHpx = (screenH * 0.82f).toInt().coerceAtLeast((320 * density).toInt()) + val gapPx = (10 * density).toInt() + val panelXpx = if (rightSide) { + (bubbleX.intValue - panelWpx - gapPx).coerceAtLeast(marginPx) + } else { + (bubbleX.intValue + bubbleSizePx + gapPx).coerceAtMost((screenW - panelWpx - marginPx).coerceAtLeast(marginPx)) + } + val panelYpx = bubbleY.intValue.coerceIn(marginPx, (screenH - panelHpx - marginPx).coerceAtLeast(marginPx)) + val originY = if (panelHpx > 0) ((bubbleY.intValue - panelYpx).toFloat() / panelHpx).coerceIn(0f, 1f) else 0f + val origin = TransformOrigin(if (rightSide) 1f else 0f, originY) + + Box(Modifier.fillMaxSize()) { + Box( + Modifier.fillMaxSize().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { collapse() }, + ) + AnimatedVisibility( + visible = visible, + enter = scaleIn(transformOrigin = origin) + fadeIn(), + modifier = Modifier.offset { IntOffset(panelXpx, panelYpx) }, + ) { + Surface( + color = BgDark, + shape = RoundedCornerShape(18.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, CardBorder), + shadowElevation = 12.dp, + modifier = Modifier.width((panelWpx / density).dp).height((panelHpx / density).dp), + ) { + Column(Modifier.fillMaxSize().imePadding()) { + Box( + Modifier.fillMaxWidth().background(SurfaceDark).padding(horizontal = 4.dp, vertical = 8.dp), + ) { + val title = current?.let { it.name.ifBlank { it.steamId.toString() } } + ?: stringResource(R.string.steam_chat_heads_friends_title) + Text( + title, + color = TextPrimary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.Center).fillMaxWidth().padding(horizontal = 96.dp), + ) + if (convId != 0L) { + IconButton(onClick = { conversationId.longValue = 0L }, modifier = Modifier.align(Alignment.CenterStart)) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.steam_common_back), tint = TextPrimary) + } + } + IconButton(onClick = { collapse() }, modifier = Modifier.align(Alignment.CenterEnd)) { + Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.steam_common_back), tint = TextSecondary) + } + } + Crossfade(targetState = convId, label = "panelBody", modifier = Modifier.weight(1f)) { id -> + val conv = friends.firstOrNull { it.steamId == id } + if (id != 0L && conv != null) { + ConversationView(conv, Modifier.fillMaxSize()) + } else { + ConversationListView(ordered, unread, Modifier.fillMaxSize()) { picked -> + openConversation(picked) + } + } + } + } + } + } + Box( + modifier = Modifier + .offset { IntOffset(bubbleX.intValue, bubbleY.intValue) } + .clickable { collapse() }, + ) { + BubbleContent(head, unread.values.sum()) + } + } + } + + @Composable + private fun ConversationListView( + list: List, + unread: Map, + modifier: Modifier, + onPick: (Long) -> Unit, + ) { + if (list.isEmpty()) { + Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.steam_friends_none_loaded), color = TextSecondary, style = MaterialTheme.typography.bodyMedium) + } + return + } + LazyColumn(modifier.fillMaxWidth(), contentPadding = PaddingValues(vertical = 6.dp)) { + items(list, key = { it.steamId }) { f -> FriendRow(f, unread[f.steamId] ?: 0) { onPick(f.steamId) } } + } + } + + @Composable + private fun FriendRow(f: SteamFriendEntry, unread: Int, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(40.dp).clip(CircleShape).background(SurfaceDark), contentAlignment = Alignment.Center) { + if (f.avatarUrl != null) { + AsyncImage(model = overlayImage(f.avatarUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(40.dp).clip(CircleShape)) + } + } + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + f.name.ifBlank { f.steamId.toString() }, + color = if (f.isOnline) TextPrimary else TextSecondary, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + if (f.isPlayingGame) f.gameName.ifBlank { stringResource(R.string.steam_friends_in_game) } + else if (f.isOnline) stringResource(R.string.stores_accounts_status_online) + else stringResource(R.string.stores_accounts_status_offline), + color = if (f.isPlayingGame) Accent else TextSecondary, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (f.isPlayingGame && f.gameCapsuleUrl != null) { + Spacer(Modifier.width(8.dp)) + AsyncImage( + model = overlayImage(f.gameCapsuleUrl), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.width(56.dp).height(21.dp).clip(RoundedCornerShape(3.dp)).background(BgDark), + ) + } + if (unread > 0) { + Box(Modifier.size(20.dp).clip(CircleShape).background(Danger), contentAlignment = Alignment.Center) { + Text(if (unread > 9) "9+" else unread.toString(), color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelSmall) + } + } + } + } + + @Composable + private fun ConversationView(friend: SteamFriendEntry, modifier: Modifier) { + val messages = remember(friend.steamId) { SnapshotStateList() } + var input by remember(friend.steamId) { mutableStateOf("") } + var sending by remember(friend.steamId) { mutableStateOf(false) } + val listState = rememberLazyListState() + val scope = androidx.compose.runtime.rememberCoroutineScope() + + DisposableEffect(friend.steamId) { + SteamService.instance?.setActiveConversation(friend.steamId) + onDispose { SteamService.instance?.clearActiveConversation(friend.steamId) } + } + LaunchedEffect(friend.steamId) { + messages.clear() + messages.addAll(SteamService.instance?.loadChatHistory(friend.steamId) ?: emptyList()) + if (messages.isNotEmpty()) listState.scrollToItem(messages.size - 1) + SteamService.instance?.incomingChat?.collect { (fid, m) -> + if (fid != friend.steamId) return@collect + val known = messages.map { it.timestamp to it.ordinal }.toHashSet() + if (m.timestamp != 0 && (m.timestamp to m.ordinal) in known) return@collect + val optIdx = if (m.fromSelf) messages.indexOfFirst { it.fromSelf && it.timestamp == 0 && it.text == m.text } else -1 + if (optIdx >= 0) messages[optIdx] = m else { + messages.add(m) + listState.animateScrollToItem(messages.size - 1) + } + } + } + + fun send() { + val text = input.trim() + if (text.isEmpty() || sending) return + sending = true + input = "" + val optimistic = SteamChatMessage(fromSelf = true, text = text, timestamp = 0, ordinal = 0) + messages.add(optimistic) + scope.launch { + listState.animateScrollToItem(messages.size - 1) + val ok = SteamService.instance?.sendChatMessage(friend.steamId, text) ?: false + if (!ok) { + val idx = messages.indexOf(optimistic) + if (idx >= 0) messages[idx] = optimistic.copy(text = "$text " + getString(R.string.steam_chat_not_sent)) + } + sending = false + } + } + + Column(modifier.fillMaxWidth()) { + LazyColumn( + Modifier.weight(1f).fillMaxWidth().padding(horizontal = 10.dp), + state = listState, + verticalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(messages.size) { i -> OverlayMessageBubble(messages[i]) } + } + Row( + Modifier.fillMaxWidth().background(SurfaceDark).padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { + headFriendId.longValue = friend.steamId + collapse() + runCatching { + startActivity( + Intent(this@ChatOverlayService, ChatImagePickerActivity::class.java) + .putExtra(ChatImagePickerActivity.EXTRA_FRIEND_ID, friend.steamId) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + }) { + Icon(Icons.Outlined.Image, contentDescription = stringResource(R.string.steam_chat_send_image), tint = Accent) + } + OutlinedTextField( + value = input, + onValueChange = { input = it }, + placeholder = { Text(stringResource(R.string.steam_chat_message_hint), color = TextSecondary) }, + singleLine = true, + modifier = Modifier.weight(1f), + keyboardOptions = KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { send() }), + colors = TextFieldDefaults.colors( + focusedContainerColor = BgDark, + unfocusedContainerColor = BgDark, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + cursorColor = Accent, + focusedIndicatorColor = Accent, + unfocusedIndicatorColor = CardBorder, + ), + ) + Spacer(Modifier.width(6.dp)) + IconButton(onClick = { send() }, enabled = input.isNotBlank() && !sending) { + Icon( + Icons.AutoMirrored.Outlined.Send, + contentDescription = stringResource(R.string.steam_chat_send), + tint = if (input.isNotBlank() && !sending) Accent else TextSecondary, + ) + } + } + } + } + + @Composable + private fun OverlayMessageBubble(message: SteamChatMessage) { + val url = overlayImageUrlOf(message.text) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = if (message.fromSelf) Arrangement.End else Arrangement.Start, + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = if (message.fromSelf) Accent.copy(alpha = 0.18f) else SurfaceDark, + modifier = Modifier.widthIn(max = 260.dp), + ) { + if (url != null) { + AsyncImage( + model = overlayImage(url), + contentDescription = stringResource(R.string.steam_chat_image), + contentScale = ContentScale.Fit, + modifier = Modifier.padding(4.dp).width(220.dp).heightIn(min = 100.dp, max = 220.dp).clip(RoundedCornerShape(10.dp)).background(BgDark), + ) + } else { + Text(message.text, color = TextPrimary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp)) + } + } + } + } + + private fun buildBubbleParams(): WindowManager.LayoutParams = + WindowManager.LayoutParams( + // Fixed size, not WRAP_CONTENT, so the overlay can't mis-measure to full screen. + bubbleSizePx, + bubbleSizePx, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.TOP or Gravity.START + } + + private fun buildPanelParams(): WindowManager.LayoutParams = + WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.TOP or Gravity.START + softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + } + + private fun buildTargetParams(): WindowManager.LayoutParams = + WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + y = (64 * density).toInt() + } + + private class OverlayLifecycleOwner : LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner { + private val lifecycleRegistry = LifecycleRegistry(this) + private val store = ViewModelStore() + private val savedStateController = SavedStateRegistryController.create(this) + override val lifecycle: Lifecycle get() = lifecycleRegistry + override val viewModelStore: ViewModelStore get() = store + override val savedStateRegistry: SavedStateRegistry get() = savedStateController.savedStateRegistry + + fun create() { + savedStateController.performRestore(null) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + fun destroy() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + store.clear() + } + } + + companion object { + private const val EXTRA_FRIEND_ID = "friendId" + + fun onIncoming(context: Context, friendId: Long) { + if (!PrefManager.chatHeadsEnabled || !Settings.canDrawOverlays(context)) return + val intent = Intent(context, ChatOverlayService::class.java).putExtra(EXTRA_FRIEND_ID, friendId) + runCatching { context.startService(intent) } + } + + fun start(context: Context) { + if (!PrefManager.chatHeadsEnabled || !Settings.canDrawOverlays(context)) return + runCatching { context.startService(Intent(context, ChatOverlayService::class.java)) } + } + + fun stop(context: Context) { + runCatching { context.stopService(Intent(context, ChatOverlayService::class.java)) } + } + } +} diff --git a/app/src/main/feature/stores/steam/data/LaunchInfo.kt b/app/src/main/feature/stores/steam/data/LaunchInfo.kt index 75d747ae1..caf3ec823 100644 --- a/app/src/main/feature/stores/steam/data/LaunchInfo.kt +++ b/app/src/main/feature/stores/steam/data/LaunchInfo.kt @@ -11,6 +11,8 @@ data class LaunchInfo( val workingDir: String, val description: String, val type: String, + // Default keeps already-cached appinfo JSON (without this key) decodable. + val arguments: String = "", @Serializable(with = OsEnumSetSerializer::class) val configOS: java.util.EnumSet, val configArch: OSArch, diff --git a/app/src/main/feature/stores/steam/data/SteamChatMessage.kt b/app/src/main/feature/stores/steam/data/SteamChatMessage.kt new file mode 100644 index 000000000..0c8c278a2 --- /dev/null +++ b/app/src/main/feature/stores/steam/data/SteamChatMessage.kt @@ -0,0 +1,8 @@ +package com.winlator.cmod.feature.stores.steam.data + +data class SteamChatMessage( + val fromSelf: Boolean, + val text: String, + val timestamp: Int, + val ordinal: Int = 0, +) diff --git a/app/src/main/feature/stores/steam/data/SteamFriendEntry.kt b/app/src/main/feature/stores/steam/data/SteamFriendEntry.kt new file mode 100644 index 000000000..fe06adcd3 --- /dev/null +++ b/app/src/main/feature/stores/steam/data/SteamFriendEntry.kt @@ -0,0 +1,35 @@ +package com.winlator.cmod.feature.stores.steam.data + +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState + +data class SteamFriendEntry( + val steamId: Long, + val name: String, + val state: EPersonaState, + val gameAppId: Int = 0, + val gameName: String = "", + val avatarHash: String = "", + val connectString: String = "", +) { + val isJoinable: Boolean + get() = isPlayingGame && gameAppId > 0 && connectString.isNotBlank() + + val isOnline: Boolean + get() = state.code() in 1..6 + + val isPlayingGame: Boolean + get() = isOnline && (gameAppId > 0 || gameName.isNotBlank()) + + val avatarUrl: String? + get() = avatarHash.takeIf { it.isNotBlank() } + ?.let { "https://avatars.akamai.steamstatic.com/${it}_full.jpg" } + + // Game artwork for the app the friend is playing (Steam apps only). + val gameCapsuleUrl: String? + get() = gameAppId.takeIf { it > 0 } + ?.let { "https://cdn.cloudflare.steamstatic.com/steam/apps/$it/capsule_231x87.jpg" } + + val gameHeaderUrl: String? + get() = gameAppId.takeIf { it > 0 } + ?.let { "https://cdn.cloudflare.steamstatic.com/steam/apps/$it/header.jpg" } +} diff --git a/app/src/main/feature/stores/steam/friends/FriendsDrawerContent.kt b/app/src/main/feature/stores/steam/friends/FriendsDrawerContent.kt new file mode 100644 index 000000000..2ed817101 --- /dev/null +++ b/app/src/main/feature/stores/steam/friends/FriendsDrawerContent.kt @@ -0,0 +1,511 @@ +package com.winlator.cmod.feature.stores.steam.friends + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.RectangleShape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import coil.compose.AsyncImage +import com.winlator.cmod.R +import com.winlator.cmod.feature.stores.steam.chat.ChatOverlayService +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.utils.PrefManager + +private val BgDark = Color(0xFF18181D) +private val SurfaceDark = Color(0xFF1E252E) +private val CardBorder = Color(0xFF2A2A3A) +private val Accent = Color(0xFF1A9FFF) +private val TextPrimary = Color(0xFFF0F4FF) +private val TextSecondary = Color(0xFF7A8FA8) +private val StatusOnline = Color(0xFF3FB950) +private val StatusAway = Color(0xFFF0C040) +private val StatusOffline = Color(0xFF6E7681) + +private fun statusColor(state: EPersonaState): Color = when (state) { + EPersonaState.Online, EPersonaState.LookingToTrade, EPersonaState.LookingToPlay -> StatusOnline + EPersonaState.Away, EPersonaState.Snooze, EPersonaState.Busy -> StatusAway + else -> StatusOffline +} + +@Composable +private fun statusLabel(state: EPersonaState): String = when (state) { + EPersonaState.Online -> stringResource(R.string.stores_accounts_status_online) + EPersonaState.Away -> stringResource(R.string.stores_accounts_status_away) + EPersonaState.Snooze -> stringResource(R.string.steam_presence_snooze) + EPersonaState.Busy -> stringResource(R.string.steam_presence_busy) + EPersonaState.LookingToTrade -> stringResource(R.string.steam_presence_looking_to_trade) + EPersonaState.LookingToPlay -> stringResource(R.string.steam_presence_looking_to_play) + EPersonaState.Invisible -> stringResource(R.string.stores_accounts_status_invisible) + else -> stringResource(R.string.stores_accounts_status_offline) +} + +@Composable +fun FriendsDrawerContent( + self: SteamFriend, + friends: List, + onSetState: (EPersonaState) -> Unit, + onOpenChat: (SteamFriendEntry) -> Unit, + onJoinGame: (SteamFriendEntry) -> Unit, +) { + val inGame = friends.filter { it.isPlayingGame }.sortedBy { it.name.lowercase() } + val online = friends.filter { it.isOnline && !it.isPlayingGame }.sortedBy { it.name.lowercase() } + val offline = friends.filter { !it.isOnline }.sortedBy { it.name.lowercase() } + + ModalDrawerSheet( + drawerShape = RectangleShape, + drawerContainerColor = BgDark, + drawerContentColor = TextPrimary, + windowInsets = WindowInsets(0, 0, 0, 0), + modifier = Modifier.width(332.dp), + ) { + Column( + Modifier + .fillMaxHeight() + .statusBarsPadding() + .padding(horizontal = 14.dp, vertical = 16.dp), + ) { + SelfCard(self = self, onSetState = onSetState) + Spacer(Modifier.height(14.dp)) + Text( + text = stringResource(R.string.steam_friends_count, friends.count { it.isOnline }), + style = androidx.compose.material3.MaterialTheme.typography.labelMedium, + color = TextSecondary, + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp), + ) + LazyColumn( + Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (inGame.isNotEmpty()) { + item { SectionHeader(stringResource(R.string.steam_friends_section_in_game, inGame.size)) } + items(inGame, key = { it.steamId }) { + InGameFriendCard(it, onOpenChat, onJoinGame) + } + } + if (online.isNotEmpty()) { + item { SectionHeader(stringResource(R.string.steam_friends_section_online, online.size)) } + items(online, key = { it.steamId }) { + FriendRow(it, onOpenChat, onJoinGame) + } + } + if (offline.isNotEmpty()) { + item { SectionHeader(stringResource(R.string.steam_friends_section_offline, offline.size)) } + items(offline, key = { it.steamId }) { + FriendRow(it, onOpenChat, onJoinGame) + } + } + if (friends.isEmpty()) { + item { + Text( + stringResource(R.string.steam_friends_none_loaded), + color = TextSecondary, + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + ) + } + } + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = androidx.compose.material3.MaterialTheme.typography.labelSmall, + color = TextSecondary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 2.dp), + ) +} + +@Composable +private fun SelfCard(self: SteamFriend, onSetState: (EPersonaState) -> Unit) { + var expanded by remember { mutableStateOf(false) } + var showChatSettings by remember { mutableStateOf(false) } + Surface( + shape = RoundedCornerShape(16.dp), + color = SurfaceDark, + border = BorderStroke(1.dp, CardBorder), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Avatar(self.avatarHashUrl(), 44.dp, statusColor(self.state)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + self.name.ifBlank { stringResource(R.string.steam_friends_self_you) }, + color = TextPrimary, + fontWeight = FontWeight.Bold, + style = androidx.compose.material3.MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).clip(CircleShape).background(statusColor(self.state))) + Spacer(Modifier.width(6.dp)) + Text( + statusLabel(self.state), + color = TextSecondary, + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + } + } + Icon( + Icons.Outlined.Settings, + contentDescription = stringResource(R.string.steam_friends_settings), + tint = TextSecondary, + modifier = Modifier + .clip(CircleShape) + .clickable { showChatSettings = true } + .padding(6.dp) + .size(20.dp), + ) + Spacer(Modifier.width(2.dp)) + Text( + if (expanded) "▲" else "▼", + color = TextSecondary, + modifier = Modifier + .clip(CircleShape) + .clickable { expanded = !expanded } + .padding(6.dp), + ) + } + AnimatedVisibility(visible = expanded) { + Column { + Spacer(Modifier.height(8.dp)) + HorizontalDivider(color = TextSecondary.copy(alpha = 0.15f)) + StatusOption(stringResource(R.string.stores_accounts_status_online), StatusOnline) { onSetState(EPersonaState.Online); expanded = false } + StatusOption(stringResource(R.string.stores_accounts_status_away), StatusAway) { onSetState(EPersonaState.Away); expanded = false } + StatusOption(stringResource(R.string.stores_accounts_status_invisible), StatusOffline) { onSetState(EPersonaState.Invisible); expanded = false } + } + } + } + } + if (showChatSettings) { + ChatSettingsDialog { showChatSettings = false } + } +} + +@Composable +private fun ChatSettingsDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + var notifications by remember { mutableStateOf(PrefManager.chatNotificationsEnabled) } + var heads by remember { mutableStateOf(PrefManager.chatHeadsEnabled) } + var autoHide by remember { mutableStateOf(PrefManager.chatHeadsAutoHide) } + var inGame by remember { mutableStateOf(PrefManager.chatInGameEnabled) } + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(18.dp), + color = SurfaceDark, + border = BorderStroke(1.dp, CardBorder), + ) { + Column(Modifier.padding(18.dp).fillMaxWidth()) { + Text( + stringResource(R.string.steam_chat_settings_title), + color = TextPrimary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(10.dp)) + ChatSettingToggle( + stringResource(R.string.steam_chat_setting_notifications), + stringResource(R.string.steam_chat_setting_notifications_desc), + notifications, + ) { v -> notifications = v; PrefManager.chatNotificationsEnabled = v } + ChatSettingToggle( + stringResource(R.string.steam_chat_setting_heads), + stringResource(R.string.steam_chat_setting_heads_desc), + heads, + ) { v -> + if (v) { + if (android.provider.Settings.canDrawOverlays(context)) { + heads = true + PrefManager.chatHeadsEnabled = true + ChatOverlayService.start(context) + } else { + runCatching { + context.startActivity( + android.content.Intent( + android.provider.Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + android.net.Uri.parse("package:" + context.packageName), + ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + } + } else { + heads = false + PrefManager.chatHeadsEnabled = false + ChatOverlayService.stop(context) + } + } + ChatSettingToggle( + stringResource(R.string.steam_chat_setting_autohide), + stringResource(R.string.steam_chat_setting_autohide_desc), + autoHide, + ) { v -> autoHide = v; PrefManager.chatHeadsAutoHide = v } + ChatSettingToggle( + stringResource(R.string.steam_chat_setting_ingame), + stringResource(R.string.steam_chat_setting_ingame_desc), + inGame, + ) { v -> inGame = v; PrefManager.chatInGameEnabled = v } + } + } + } +} + +@Composable +private fun ChatSettingToggle( + title: String, + desc: String, + checked: Boolean, + onChange: (Boolean) -> Unit, +) { + Row( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, color = TextPrimary, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodyMedium) + Text(desc, color = TextSecondary, style = MaterialTheme.typography.labelSmall) + } + Spacer(Modifier.width(12.dp)) + Switch( + checked = checked, + onCheckedChange = onChange, + colors = SwitchDefaults.colors(checkedTrackColor = Accent, checkedThumbColor = Color.White), + ) + } +} + +@Composable +private fun StatusOption(label: String, dot: Color, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(vertical = 10.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(8.dp).clip(CircleShape).background(dot)) + Spacer(Modifier.width(10.dp)) + Text(label, color = TextPrimary, style = androidx.compose.material3.MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun InGameFriendCard( + friend: SteamFriendEntry, + onOpenChat: (SteamFriendEntry) -> Unit, + onJoinGame: (SteamFriendEntry) -> Unit, +) { + Surface( + shape = RoundedCornerShape(10.dp), + color = Accent.copy(alpha = 0.07f), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { onOpenChat(friend) }, + ) { + Row( + Modifier.padding(horizontal = 8.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar(friend.avatarUrl, 40.dp, StatusOnline) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + friend.name.ifBlank { friend.steamId.toString() }, + color = TextPrimary, + fontWeight = FontWeight.Medium, + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // Small game art at text height, with the game name to the right. + Row( + Modifier.padding(top = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (friend.gameCapsuleUrl != null) { + AsyncImage( + model = friend.gameCapsuleUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(56.dp) + .height(21.dp) + .clip(RoundedCornerShape(3.dp)) + .background(SurfaceDark), + ) + Spacer(Modifier.width(7.dp)) + } + Text( + friend.gameName.ifBlank { stringResource(R.string.steam_friends_in_game) }, + color = StatusOnline, + fontWeight = FontWeight.Medium, + style = androidx.compose.material3.MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (friend.isJoinable) { + Spacer(Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = Accent.copy(alpha = 0.18f), + border = BorderStroke(1.dp, Accent.copy(alpha = 0.6f)), + modifier = Modifier.clickable { onJoinGame(friend) }, + ) { + Row( + Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.PlayArrow, contentDescription = stringResource(R.string.steam_friends_join), tint = Accent, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.steam_friends_join), color = Accent, style = androidx.compose.material3.MaterialTheme.typography.labelMedium) + } + } + } + } + } +} + +@Composable +private fun FriendRow( + friend: SteamFriendEntry, + onOpenChat: (SteamFriendEntry) -> Unit, + onJoinGame: (SteamFriendEntry) -> Unit, +) { + val scale by animateFloatAsState(1f, label = "row") + Surface( + shape = RoundedCornerShape(10.dp), + color = if (friend.isPlayingGame) Accent.copy(alpha = 0.07f) else Color.Transparent, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { onOpenChat(friend) }, + ) { + Row( + Modifier.padding(horizontal = 8.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar(friend.avatarUrl, 40.dp, statusColor(friend.state), dim = !friend.isOnline) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + friend.name.ifBlank { friend.steamId.toString() }, + color = if (friend.isOnline) TextPrimary else TextSecondary, + fontWeight = FontWeight.Medium, + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val sub = when { + friend.isPlayingGame -> friend.gameName.ifBlank { stringResource(R.string.steam_friends_in_game) } + else -> statusLabel(friend.state) + } + Text( + sub, + color = if (friend.isPlayingGame) StatusOnline else TextSecondary, + style = androidx.compose.material3.MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (friend.isPlayingGame) { + Surface( + shape = RoundedCornerShape(8.dp), + color = Accent.copy(alpha = 0.18f), + border = BorderStroke(1.dp, Accent.copy(alpha = 0.6f)), + modifier = Modifier.clickable { onJoinGame(friend) }, + ) { + Row( + Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.PlayArrow, contentDescription = stringResource(R.string.steam_friends_join), tint = Accent, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.steam_friends_join), color = Accent, style = androidx.compose.material3.MaterialTheme.typography.labelMedium) + } + } + } + } + } +} + +@Composable +private fun Avatar(url: String?, size: androidx.compose.ui.unit.Dp, ring: Color, dim: Boolean = false) { + Box( + Modifier + .size(size) + .clip(CircleShape) + .background(SurfaceDark), + contentAlignment = Alignment.Center, + ) { + if (url != null) { + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(size).clip(CircleShape), + alpha = if (dim) 0.55f else 1f, + ) + } + } +} + +private fun SteamFriend.avatarHashUrl(): String? = + avatarHash.takeIf { it.isNotBlank() } + ?.let { "https://avatars.akamai.steamstatic.com/${it}_full.jpg" } diff --git a/app/src/main/feature/stores/steam/friends/SteamChatScreen.kt b/app/src/main/feature/stores/steam/friends/SteamChatScreen.kt new file mode 100644 index 000000000..d7a82811c --- /dev/null +++ b/app/src/main/feature/stores/steam/friends/SteamChatScreen.kt @@ -0,0 +1,353 @@ +package com.winlator.cmod.feature.stores.steam.friends + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.mutableStateListOf +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.winlator.cmod.R +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import coil.compose.AsyncImage +import com.winlator.cmod.feature.stores.steam.data.SteamChatMessage +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.service.SteamService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val BgDark = Color(0xFF18181D) +private val SurfaceDark = Color(0xFF1E252E) +private val Accent = Color(0xFF1A9FFF) +private val TextPrimary = Color(0xFFF0F4FF) +private val TextSecondary = Color(0xFF7A8FA8) + +private val IMG_BBCODE = Regex("""\[img](.+?)\[/img]""", RegexOption.IGNORE_CASE) +// Steam delivers chat images as [img src=URL] or as a bare UGC URL. +private val IMG_SRC = Regex("""\[img\b[^]]*?\bsrc=["']?([^\s"'\]]+)""", RegexOption.IGNORE_CASE) +private val BARE_IMG_URL = Regex("""https?://\S+\.(?:png|jpe?g|gif|webp|bmp)""", RegexOption.IGNORE_CASE) +// Public displayable Steam UGC images (not /filedownload/ endpoints, which need auth). +private val STEAM_IMG_URL = Regex("""https?://\S*images\.steamusercontent\.com/ugc/\S+""", RegexOption.IGNORE_CASE) + +private fun imageUrlOf(text: String): String? { + val t = text.trim() + return IMG_BBCODE.find(t)?.groupValues?.getOrNull(1) + ?: IMG_SRC.find(t)?.groupValues?.getOrNull(1) + ?: STEAM_IMG_URL.find(t)?.value + ?: BARE_IMG_URL.find(t)?.value +} + +@Composable +fun SteamChatScreen( + friend: SteamFriendEntry, + onClose: () -> Unit, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val messages = remember { mutableStateListOf() } + var input by remember { mutableStateOf("") } + var loading by remember { mutableStateOf(true) } + var sending by remember { mutableStateOf(false) } + var uploading by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + + val pickImage = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia(), + ) { uri -> + if (uri != null && !uploading) { + uploading = true + scope.launch { + val bytes = withContext(Dispatchers.IO) { + runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } + .getOrNull() + } + if (bytes == null || bytes.isEmpty()) { + uploading = false + return@launch + } + val mime = context.contentResolver.getType(uri) ?: "image/png" + val ext = when { + mime.contains("jpeg") || mime.contains("jpg") -> "jpeg" + mime.contains("gif") -> "gif" + mime.contains("webp") -> "webp" + else -> "png" + } + val url = SteamService.instance?.sendChatImage(friend.steamId, bytes, "image.$ext") + if (url.isNullOrBlank()) { + messages.add(SteamChatMessage(fromSelf = true, text = context.getString(R.string.steam_chat_image_failed), timestamp = 0)) + listState.animateScrollToItem(messages.size - 1) + } + uploading = false + } + } + } + + LaunchedEffect(friend.steamId) { + loading = true + messages.clear() + SteamService.instance?.setActiveConversation(friend.steamId) + messages.addAll(SteamService.instance?.loadChatHistory(friend.steamId) ?: emptyList()) + loading = false + if (messages.isNotEmpty()) listState.scrollToItem(messages.size - 1) + SteamService.instance?.incomingChat?.collect { (fid, m) -> + if (fid != friend.steamId) return@collect + val known = messages.map { it.timestamp to it.ordinal }.toHashSet() + if (m.timestamp != 0 && (m.timestamp to m.ordinal) in known) return@collect + val mImg = imageUrlOf(m.text) + val optIdx = if (m.fromSelf) { + messages.indexOfFirst { + it.fromSelf && it.timestamp == 0 && + (it.text == m.text || (mImg != null && imageUrlOf(it.text) == mImg)) + } + } else -1 + if (optIdx >= 0) { + messages[optIdx] = m + } else { + messages.add(m) + listState.animateScrollToItem(messages.size - 1) + } + } + } + DisposableEffect(friend.steamId) { + onDispose { SteamService.instance?.clearActiveConversation(friend.steamId) } + } + + fun send() { + val text = input.trim() + if (text.isEmpty() || sending) return + sending = true + input = "" + val optimistic = SteamChatMessage(fromSelf = true, text = text, timestamp = 0, ordinal = 0) + messages.add(optimistic) + scope.launch { + listState.animateScrollToItem(messages.size - 1) + val ok = SteamService.instance?.sendChatMessage(friend.steamId, text) ?: false + if (!ok) { + val idx = messages.indexOf(optimistic) + if (idx >= 0) messages[idx] = optimistic.copy(text = "$text " + context.getString(R.string.steam_chat_not_sent)) + } + sending = false + } + } + + Surface(color = BgDark, modifier = Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize().statusBarsPadding()) { + Row( + Modifier + .fillMaxWidth() + .background(SurfaceDark) + .padding(horizontal = 6.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onClose) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.steam_common_back), tint = TextPrimary) + } + Box( + Modifier.size(38.dp).clip(CircleShape).background(BgDark), + contentAlignment = Alignment.Center, + ) { + if (friend.avatarUrl != null) { + AsyncImage( + model = friend.avatarUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(38.dp).clip(CircleShape), + ) + } + } + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + friend.name.ifBlank { friend.steamId.toString() }, + color = TextPrimary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleSmall, + ) + Text( + if (friend.isPlayingGame) friend.gameName.ifBlank { stringResource(R.string.steam_friends_in_game) } + else if (friend.isOnline) stringResource(R.string.stores_accounts_status_online) else stringResource(R.string.stores_accounts_status_offline), + color = if (friend.isOnline) Accent else TextSecondary, + style = MaterialTheme.typography.labelSmall, + ) + } + } + + Box(Modifier.weight(1f).fillMaxWidth()) { + if (loading) { + CircularProgressIndicator( + color = Accent, + modifier = Modifier.size(28.dp).align(Alignment.Center), + ) + } else if (messages.isEmpty()) { + Text( + stringResource(R.string.steam_chat_empty), + color = TextSecondary, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.align(Alignment.Center), + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + item { Spacer(Modifier.height(8.dp)) } + items(messages) { msg -> MessageBubble(msg) } + item { Spacer(Modifier.height(8.dp)) } + } + } + } + + if (uploading) { + Row( + Modifier.fillMaxWidth().background(SurfaceDark).padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(color = Accent, strokeWidth = 2.dp, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(10.dp)) + Text(stringResource(R.string.steam_chat_uploading_image), color = TextSecondary, style = MaterialTheme.typography.labelMedium) + } + } + Row( + Modifier + .fillMaxWidth() + .background(SurfaceDark) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + pickImage.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + enabled = !uploading, + ) { + Icon( + Icons.Outlined.Image, + contentDescription = stringResource(R.string.steam_chat_send_image), + tint = if (uploading) TextSecondary else Accent, + ) + } + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.steam_chat_message_hint), color = TextSecondary) }, + maxLines = 4, + shape = RoundedCornerShape(22.dp), + keyboardActions = KeyboardActions(onSend = { send() }), + colors = TextFieldDefaults.colors( + focusedContainerColor = BgDark, + unfocusedContainerColor = BgDark, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + cursorColor = Accent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + Spacer(Modifier.width(6.dp)) + IconButton(onClick = { send() }, enabled = input.isNotBlank() && !sending) { + Icon( + Icons.AutoMirrored.Outlined.Send, + contentDescription = stringResource(R.string.steam_chat_send), + tint = if (input.isNotBlank() && !sending) Accent else TextSecondary, + ) + } + } + } + } +} + +@Composable +private fun MessageBubble(msg: SteamChatMessage) { + val imageUrl = imageUrlOf(msg.text) + val bubbleColor = if (msg.fromSelf) Accent.copy(alpha = 0.22f) else SurfaceDark + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = if (msg.fromSelf) Arrangement.End else Arrangement.Start, + ) { + Surface( + shape = RoundedCornerShape( + topStart = 14.dp, + topEnd = 14.dp, + bottomStart = if (msg.fromSelf) 14.dp else 4.dp, + bottomEnd = if (msg.fromSelf) 4.dp else 14.dp, + ), + color = bubbleColor, + modifier = Modifier.widthIn(max = 260.dp), + ) { + if (imageUrl != null) { + AsyncImage( + model = imageUrl, + contentDescription = stringResource(R.string.steam_chat_image), + contentScale = ContentScale.Fit, + modifier = Modifier + .padding(4.dp) + .width(236.dp) + .heightIn(min = 120.dp, max = 240.dp) + .clip(RoundedCornerShape(10.dp)) + .background(BgDark), + ) + } else { + Text( + msg.text, + color = TextPrimary, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + } +} diff --git a/app/src/main/feature/stores/steam/service/GameSessionState.kt b/app/src/main/feature/stores/steam/service/GameSessionState.kt new file mode 100644 index 000000000..36fc548ae --- /dev/null +++ b/app/src/main/feature/stores/steam/service/GameSessionState.kt @@ -0,0 +1,24 @@ +package com.winlator.cmod.feature.stores.steam.service + +import android.content.Context +import com.winlator.cmod.feature.stores.steam.chat.ChatOverlayService +import com.winlator.cmod.feature.stores.steam.utils.PrefManager + +object GameSessionState { + @JvmStatic + @Volatile + var inGame: Boolean = false + private set + + @JvmStatic + fun setInGame(context: Context, value: Boolean) { + inGame = value + runCatching { + if (value && !PrefManager.chatInGameEnabled) { + ChatOverlayService.stop(context) + } else { + ChatOverlayService.start(context) + } + } + } +} diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index d2f6cefd3..c324a2d87 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -32,6 +32,7 @@ import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo import com.winlator.cmod.feature.stores.steam.data.SteamApp import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry import com.winlator.cmod.feature.stores.steam.data.SteamLicense import com.winlator.cmod.feature.stores.steam.data.UserFileInfo import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao @@ -92,9 +93,10 @@ import com.winlator.cmod.runtime.display.environment.ImageFs import com.winlator.cmod.runtime.system.GPUInformation import com.winlator.cmod.runtime.system.SessionKeepAliveService import com.winlator.cmod.shared.android.AppTerminationHelper -import com.winlator.cmod.shared.ui.toast.WinToast import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.FileUtils import com.winlator.cmod.shared.io.StorageUtils +import com.winlator.cmod.shared.ui.toast.WinToast import dagger.hilt.android.AndroidEntryPoint import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags @@ -122,7 +124,9 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlin.coroutines.coroutineContext import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.filter @@ -285,6 +289,25 @@ class SteamService : Service() { ) val localPersona = _localPersona.asStateFlow() + private val _friendsList = MutableStateFlow>(emptyList()) + val friendsList = _friendsList.asStateFlow() + + private val _incomingChat = + MutableSharedFlow>( + replay = 32, + extraBufferCapacity = 256, + ) + val incomingChat = _incomingChat.asSharedFlow() + + private val _unreadCounts = MutableStateFlow>(emptyMap()) + val unreadCounts = _unreadCounts.asStateFlow() + + private val _recentChats = MutableStateFlow>(emptyMap()) + val recentChats = _recentChats.asStateFlow() + + private val activeConversations = java.util.concurrent.ConcurrentHashMap() + private var messagePollerJob: Job? = null + data class ManifestSizes( val installSize: Long = 0L, val downloadSize: Long = 0L, @@ -364,6 +387,46 @@ class SteamService : Service() { cachedAchievementsAppId = null } + // Generate (CM schema + unlock state) and return achievements for a game. + suspend fun loadAchievements( + appId: Int, + configDirectory: String, + ): List { + runCatching { generateAchievements(appId, configDirectory) } + return if (cachedAchievementsAppId == appId) cachedAchievements ?: emptyList() else emptyList() + } + + // Overlay the real unlock state onto schema-derived achievement definitions. + private suspend fun mergeAchievementUnlockState( + appId: Int, + achievements: List, + nameToBlockBit: Map>, + ): List { + if (achievements.isEmpty() || nameToBlockBit.isEmpty()) return achievements + val statsJson = withWnSession { s -> s.getUserStatsFull(appId) } ?: return achievements + val blockUnlock = HashMap>() + runCatching { + val obj = JSONObject(statsJson) + if (obj.optInt("eresult", 2) != EResult.OK.code()) return achievements + val blocks = obj.optJSONArray("achievementBlocks") ?: return achievements + for (i in 0 until blocks.length()) { + val b = blocks.getJSONObject(i) + val times = b.optJSONArray("unlockTimes") + val list = ArrayList(times?.length() ?: 0) + for (j in 0 until (times?.length() ?: 0)) list.add(times!!.getLong(j)) + blockUnlock[b.optInt("achievementId")] = list + } + } + if (blockUnlock.isEmpty()) return achievements + val unlockedTotal = blockUnlock.values.sumOf { times -> times.count { it != 0L } } + Timber.i("Achievements: app=$appId merged unlock state ($unlockedTotal unlocked across ${blockUnlock.size} blocks)") + return achievements.map { ach -> + val mapped = nameToBlockBit[ach.name] ?: return@map ach + val t = blockUnlock[mapped.first]?.getOrNull(mapped.second) ?: 0L + if (t != 0L) ach.copy(unlocked = true, unlockTimestamp = t.toInt()) else ach.copy(unlocked = false) + } + } + private fun downloadUrlsFor(fileName: String): List { val alternate = when (fileName) { @@ -2306,6 +2369,146 @@ class SteamService : Service() { return container.executablePath.ifEmpty { getInstalledExe(gameId) } } + private fun findSteamShortcut( + context: Context, + appId: Int, + ) = ContainerManager(context).loadShortcuts().find { + it.getExtra("game_source") == "STEAM" && it.getExtra("app_id") == appId.toString() + } + + /** + * Lightweight read of the Steam shortcut's `[Extra Data]` section straight + * from its .desktop file, with the owning container's root dir. Deliberately + * NOT findSteamShortcut/loadShortcuts on read-only paths: the Shortcut + * constructor decodes cover-art bitmaps, runs PE icon extraction and can + * rewrite .desktop files — far too heavy (and write-racy) for hot paths + * that only need a couple of strings. Returns null when no Steam shortcut + * exists for [appId]. + */ + private fun readSteamShortcutExtras( + context: Context, + appId: Int, + ): Pair, File>? { + val appIdStr = appId.toString() + val homeDir = File(ImageFs.find(context).rootDir, "home") + val containerDirs = + homeDir.listFiles { f -> f.isDirectory && f.name.startsWith("${ImageFs.USER}-") } + ?: return null + for (containerDir in containerDirs) { + val desktopDir = File(containerDir, ".wine/drive_c/users/${ImageFs.USER}/Desktop") + val files = desktopDir.listFiles { f -> f.name.endsWith(".desktop") } ?: continue + for (file in files) { + var section = "" + val extras = mutableMapOf() + // FileUtils.readLines (also used by Shortcut's parser) returns + // what it could read on IO errors — one corrupt .desktop file + // must not abort the scan of the remaining containers. + for (raw in FileUtils.readLines(file)) { + val line = raw.trim() + if (line.isEmpty() || line.startsWith("#")) continue + if (line.startsWith("[")) { + section = line.substringAfter("[").substringBefore("]") + continue + } + if (section != "Extra Data") continue + val key = line.substringBefore("=", "") + if (key.isNotEmpty()) extras[key] = line.substringAfter("=") + } + if (extras["game_source"] == "STEAM" && extras["app_id"] == appIdStr) { + return extras to containerDir + } + } + } + return null + } + + /** Reads executablePath from a container's `.container` config without Container construction. */ + private fun readContainerExecutablePath(containerDir: File): String = + runCatching { + val configFile = File(containerDir, ".container") + if (!configFile.isFile) return "" + JSONObject(configFile.readText()).optString("executablePath", "") + }.getOrElse { "" } + + /** + * Persists the user's launch-option choice (an appinfo `config.launch` entry) on + * the game's shortcut + container, matching resolveRelativeGameExe's priority: + * shortcut `launch_exe_path` first, container.executablePath as the synced + * fallback. The option's own arguments go to `launch_exe_args`, kept separate + * from the user-editable custom args (`execArgs`). Call on an IO dispatcher. + */ + fun setSelectedLaunchOption( + context: Context, + appId: Int, + executable: String, + arguments: String, + ): Boolean { + var shortcut = findSteamShortcut(context, appId) + if (shortcut == null) { + // Installs from older builds may predate shortcut creation on download. + createSteamShortcut(context, appId) + shortcut = findSteamShortcut(context, appId) + } + if (shortcut == null) { + Timber.w("setSelectedLaunchOption: no shortcut for appId=$appId") + return false + } + shortcut.putExtra("launch_exe_path", executable) + shortcut.putExtra("launch_exe_args", arguments.ifBlank { null }) + shortcut.saveData() + shortcut.container?.let { + it.executablePath = executable + it.saveData() + } + return true + } + + /** + * Currently effective launch option as (executable, arguments), resolved in the + * same order the launch path uses: shortcut `launch_exe_path` first, the owning + * container's executablePath as fallback, then the installed exe. Reads the + * .desktop/.container files directly (this runs on every game-detail open). + * Call on an IO dispatcher. + */ + fun getSelectedLaunchOption( + context: Context, + appId: Int, + ): Pair { + val found = runCatching { readSteamShortcutExtras(context, appId) }.getOrNull() + val extras = found?.first.orEmpty() + val exe = + extras["launch_exe_path"].orEmpty().ifBlank { + found?.second?.let { readContainerExecutablePath(it) }.orEmpty().ifBlank { + getInstalledExe(appId) + } + } + return exe.replace('\\', '/') to extras["launch_exe_args"].orEmpty() + } + + /** + * Persists the user's beta-branch choice on the game's shortcut. + * Pass a blank [branchName] to clear the selection (reverts to public). + * Call on an IO dispatcher. + */ + fun setSelectedBetaBranch( + context: Context, + appId: Int, + branchName: String, + ): Boolean { + var shortcut = findSteamShortcut(context, appId) + if (shortcut == null) { + createSteamShortcut(context, appId) + shortcut = findSteamShortcut(context, appId) + } + if (shortcut == null) { + Timber.w("setSelectedBetaBranch: no shortcut for appId=$appId") + return false + } + shortcut.putExtra("selectedBranch", branchName.ifBlank { null }) + shortcut.saveData() + return true + } + suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { val appDirPath = getAppDirPath(appId) @@ -2633,7 +2836,7 @@ class SteamService : Service() { appId = appId, downloadableDepots = downloadableDepots, userSelectedDlcAppIds = effectiveDlcAppIds, - branch = "public", + branch = resolveSelectedBetaName(appId).ifBlank { "public" }, includeInstalledDepots = includeInstalledDepots, enableVerify = enableVerify, allowPersistedProgress = allowPersistedProgress, @@ -4458,6 +4661,8 @@ class SteamService : Service() { runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_PATCHED) } runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_UNPACK_CHECKED) } + pruneStaleDepotManifestCache(appDirPath) + // Same reason as the runCatching above: a Room exception // here used to FAIL a fully-downloaded game with COMPLETE // marker already on disk. @@ -4997,10 +5202,9 @@ class SteamService : Service() { } val generator = StatsAchievementsGenerator() val result = generator.generateStatsAchievements(schemaArray, configDirectory) - cachedAchievements = result.achievements - cachedAchievementsAppId = appId - val nameToBlockBit = result.nameToBlockBit + cachedAchievements = mergeAchievementUnlockState(appId, result.achievements, nameToBlockBit) + cachedAchievementsAppId = appId if (nameToBlockBit.isNotEmpty()) { val mappingJson = JSONObject() nameToBlockBit.forEach { (name, pair) -> @@ -5354,7 +5558,7 @@ class SteamService : Service() { suspend fun prepareLibSteamClientForLaunch(appId: Int) { if (appId <= 0) return startOverlayPollLoop() - val selectedBranch = resolveSelectedBetaName(appId) + val selectedBranch = recoverSelectedBetaName(appId) val baseStatePrimed = runCatching { primeLibSteamClientLaunchState(appId, selectedBranch) } .getOrElse { e -> @@ -5443,16 +5647,130 @@ class SteamService : Service() { if (appId <= 0) return "" val svc = instance ?: return "" return runCatching { - for (sc in ContainerManager(svc).loadShortcuts()) { - val scAppId = sc.getExtra("app_id").toIntOrNull() ?: continue - if (scAppId != appId) continue - val branch = sc.getExtra("selectedBranch").trim() - if (branch.isNotEmpty()) return@runCatching branch - } - "" + readSteamShortcutExtras(svc, appId)?.first?.get("selectedBranch").orEmpty().trim() }.getOrElse { "" } } + /** + * Like [resolveSelectedBetaName], but when the shortcut record is gone + * (reinstalling WinNative wipes app-private shortcuts while game files + * on external storage survive) it recovers the branch the installed + * build is actually on and heals the shortcut. Steam persists the beta + * key with the install (appmanifest ACF UserConfig/betakey); our durable + * analogs in the surviving game dir are, in order of trust: + * 1. `.DepotDownloader/depot.config` — the installed manifest gids, + * matched exactly against each beta's current PICS manifests + * (evidence of what the FILES are); + * 2. `steam_settings/configs.app.ini` `branch_name` — the selection + * recorded at the game's last launch. + * Returns "" (public) when neither source identifies a beta — e.g. the + * installed build predates the current PICS manifests and the game was + * never launched. Call on an IO dispatcher. + */ + // Apps whose branch recovery already ran this process — recovery can only + // succeed right after a WinNative reinstall, so one failed attempt per + // process is enough (a success heals the shortcut and short-circuits + // every later call through the persisted fast path above). + private val betaRecoveryAttemptedApps = + java.util.Collections.synchronizedSet(mutableSetOf()) + + suspend fun recoverSelectedBetaName(appId: Int): String { + val persisted = resolveSelectedBetaName(appId) + if (persisted.isNotEmpty()) return persisted + if (!betaRecoveryAttemptedApps.add(appId)) return "" + val svc = instance ?: return "" + return runCatching { + if (!isAppInstalled(appId)) return@runCatching "" + val app = withContext(Dispatchers.IO) { svc.appDao.findApp(appId) } ?: return@runCatching "" + val betaNames = app.branches.keys.filterNot { it.equals("public", ignoreCase = true) } + if (betaNames.isEmpty()) return@runCatching "" + + val appDirPath = getAppDirPath(appId) + val inferred = + inferBranchFromInstalledManifests(app, betaNames, appDirPath) + ?: readBranchNameFromSettingsIni(app, appDirPath) + // Canonicalize to the PICS branches-map key: downstream + // buildId lookups (app.branches[name]) are exact-key. + ?.let { name -> betaNames.firstOrNull { it.equals(name, ignoreCase = true) } } + if (inferred.isNullOrEmpty()) return@runCatching "" + if (setSelectedBetaBranch(svc, appId, inferred)) { + Timber.i("Recovered beta branch '$inferred' for appId=$appId from the installed game dir") + } + inferred + }.getOrElse { e -> + Timber.w(e, "Beta branch recovery failed for appId=$appId") + "" + } + } + + /** + * The beta whose current PICS manifests exactly match the installed + * manifest gids — every installed depot that declares a manifest for the + * branch must match, and at least one gid must differ from public (else + * the install is indistinguishable from public and stays public). + * Null when no or several betas qualify. + */ + private fun inferBranchFromInstalledManifests( + app: SteamApp, + betaNames: List, + appDirPath: String, + ): String? { + val installed = readInstalledDepotManifestIds(appDirPath) + if (installed.isEmpty()) return null + val matches = + betaNames.filter { branch -> + var distinctFromPublic = false + var comparedAny = false + for ((depotId, installedGid) in installed) { + val depot = app.depots[depotId] ?: continue + val branchGid = + (depot.manifests[branch] ?: depot.encryptedManifests[branch])?.gid ?: continue + comparedAny = true + if (branchGid != installedGid) return@filter false + val publicGid = + (depot.manifests["public"] ?: depot.encryptedManifests["public"])?.gid + if (publicGid != branchGid) distinctFromPublic = true + } + comparedAny && distinctFromPublic + } + return matches.singleOrNull() + } + + /** + * `branch_name` from the surviving `steam_settings/configs.app.ini` + * (written at every launch by writeCompleteSettingsDir). Checked at the + * game-dir root and next to the game exe — the two places the launch + * path writes settings dirs. + */ + private fun readBranchNameFromSettingsIni( + app: SteamApp, + appDirPath: String, + ): String? { + val exeDir = + app.config.launch + .firstOrNull { it.executable.endsWith(".exe") } + ?.executable + ?.replace('\\', '/') + ?.substringBeforeLast('/', "") + .orEmpty() + val candidates = + listOfNotNull( + File(appDirPath, "steam_settings/configs.app.ini"), + exeDir.takeIf { it.isNotEmpty() }?.let { File(appDirPath, "$it/steam_settings/configs.app.ini") }, + ) + for (ini in candidates) { + if (!ini.isFile) continue + val name = + FileUtils.readLines(ini) + .firstOrNull { it.startsWith("branch_name=") } + ?.substringAfter("=") + ?.trim() + .orEmpty() + if (name.isNotEmpty() && !name.equals("public", ignoreCase = true)) return name + } + return null + } + suspend fun refreshEncryptedAppTicketForLibSteamClient(appId: Int): Boolean { if (appId <= 0) return false val instance = SteamService.instance ?: return false @@ -7159,6 +7477,7 @@ class SteamService : Service() { instance?.picsGetProductInfoJob?.cancel() instance?.picsChangesCheckerJob?.cancel() instance?.friendCheckerJob?.cancel() + instance?.messagePollerJob?.cancel() // Emit event synchronously so the UI can react in the same frame PluviaApp.events.emit(SteamEvent.LoggedOut(username)) @@ -7311,14 +7630,19 @@ class SteamService : Service() { suspend fun isUpdatePending( appId: Int, - branch: String = "public", + branch: String? = null, ): Boolean = checkForAppUpdate(appId, branch).hasUpdate suspend fun checkForAppUpdate( appId: Int, - branch: String = "public", + // null = the game's selected beta, recovering a selection lost to an + // app reinstall — otherwise a reinstalled beta install would be + // diffed against public and silently downgraded by the update. + requestedBranch: String? = null, ): SteamUpdateInfo = withContext(Dispatchers.IO) { + val branch = requestedBranch ?: recoverSelectedBetaName(appId).ifBlank { "public" } + fun SteamUpdateInfo.logged(): SteamUpdateInfo { Timber.i( "Steam update check result: appId=$appId branch=$branch " + @@ -7426,6 +7750,23 @@ class SteamService : Service() { return null } + /** + * Best-effort re-fetch + persist of one app's PICS appinfo. Used to heal + * cached rows that predate newly parsed fields (e.g. LaunchInfo.arguments). + * Returns false when offline / fetch fails; callers keep using the cached row. + */ + suspend fun refreshAppInfoFromPics(appId: Int): Boolean { + val fresh = + try { + fetchLatestSteamAppInfo(appId) + } catch (e: Exception) { + Timber.w(e, "refreshAppInfoFromPics failed for appId=$appId") + null + } ?: return false + persistLatestSteamAppInfo(appId, fresh) + return true + } + private suspend fun persistLatestSteamAppInfo( appId: Int, remoteSteamApp: SteamApp, @@ -7470,6 +7811,33 @@ class SteamService : Service() { emptyMap() } + /** + * Prunes stale "{depotId}_{gid}.manifest" caches after a completed + * download: a branch switch or ordinary update leaves the previous + * build's manifests behind, wasting disk and — when depot.config is + * missing an entry — letting checkForAppUpdate's cache fallback mistake + * an old build for installed. Keeps only what depot.config says is + * current; legacy installs with no depot.config are left untouched + * because their fallback needs the cached files. + */ + private fun pruneStaleDepotManifestCache(appDirPath: String) { + runCatching { + val installedManifests = readInstalledDepotManifestIds(appDirPath) + if (installedManifests.isEmpty()) return + File(appDirPath, ".DepotDownloader") + .listFiles { f -> f.isFile && f.name.endsWith(".manifest") } + ?.forEach { f -> + val parts = f.name.removeSuffix(".manifest").split('_') + if (parts.size != 2) return@forEach + val depotId = parts[0].toIntOrNull() ?: return@forEach + val gid = parts[1].toLongOrNull() ?: return@forEach + if (installedManifests[depotId] != gid && f.delete()) { + Timber.i("Pruned stale depot manifest cache ${f.name} at $appDirPath") + } + } + }.onFailure { e -> Timber.w(e, "Stale manifest prune failed for $appDirPath") } + } + private fun cleanupCancelledUpdate(appDirPath: String) { MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) @@ -7940,6 +8308,7 @@ class SteamService : Service() { refreshTokenWatchdogJob?.cancel() picsChangesCheckerJob?.cancel() picsGetProductInfoJob?.cancel() + messagePollerJob?.cancel() wnSession?.let { s -> runCatching { s.disconnect() } } return true } @@ -8008,6 +8377,7 @@ class SteamService : Service() { refreshTokenWatchdogJob?.cancel() picsChangesCheckerJob?.cancel() picsGetProductInfoJob?.cancel() + messagePollerJob?.cancel() wnSession?.let { s -> runCatching { s.logOffAndDisconnect(500) } } } @@ -8312,6 +8682,8 @@ class SteamService : Service() { picsChangesCheckerJob = continuousPICSChangesChecker() picsGetProductInfoJob?.cancel() picsGetProductInfoJob = continuousPICSGetProductInfo() + messagePollerJob?.cancel() + messagePollerJob = continuousIncomingMessagePoller() // Repair legacy depots whose stored download>size was frozen by the change-number skip. healCorruptManifestDownloadSizes() @@ -8539,6 +8911,253 @@ class SteamService : Service() { Timber.i("Pushed $pushed friend persona(s) to libsteamclient.so (snapshot persisted)") } + suspend fun refreshFriends() { + val svc = instance ?: return + val ids = withWnSession { s -> s.getFriendsList() } ?: LongArray(0) + if (ids.isEmpty()) return + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient.setFriendsList(ids) + val merged = LinkedHashMap() + for (id in ids) merged[id] = SteamFriendEntry(steamId = id, name = "", state = EPersonaState.Offline) + fun mergeJson(json: String?) { + val arr = try { JSONArray(json ?: "[]") } catch (_: Exception) { JSONArray() } + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + val sid = o.optLong("sid", 0L) + if (sid == 0L) continue + merged[sid] = SteamFriendEntry( + steamId = sid, + name = o.optString("name", ""), + state = EPersonaState.from(o.optInt("state", 0)) ?: EPersonaState.Offline, + gameAppId = o.optInt("app", 0), + gameName = o.optString("gameName", ""), + avatarHash = o.optString("avatarHash", ""), + connectString = o.optString("connect", ""), + ) + } + } + runCatching { + mergeJson(com.winlator.cmod.feature.stores.steam.utils.PrefManager.friendsSnapshotJson) + } + svc._friendsList.value = merged.values.toList() + withWnSession { s -> s.requestFriendPersonas(ids, personaStateRequested = 0xffff) } + var gotLive = false + for (attempt in 0 until 20) { + if (attempt > 0 && attempt % 5 == 0) { + withWnSession { s -> s.requestFriendPersonas(ids, personaStateRequested = 0xffff) } + } + val json = withWnSession { s -> s.getFriendPersonas() } + if (!json.isNullOrBlank() && json != "[]") { + mergeJson(json) + gotLive = true + runCatching { + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient.pushFriendPersonasJson(json, persistSnapshot = true) + } + } + // Resolve game titles for in-game friends (Steam omits game_name for Steam apps). + for ((id, entry) in merged.toList()) { + if (entry.isOnline && entry.gameName.isBlank() && entry.gameAppId > 0) { + val name = resolveGameName(entry.gameAppId) + if (name.isNotBlank()) merged[id] = entry.copy(gameName = name) + } + } + svc._friendsList.value = merged.values.toList() + if (gotLive && merged.values.count { it.name.isNotBlank() } >= ids.size) break + kotlinx.coroutines.delay(1000L) + } + } + + suspend fun syncFriendsPresence() { + val svc = instance ?: return + val current = svc._friendsList.value + if (current.isEmpty()) return + val json = withContext(Dispatchers.IO) { withWnSession { s -> s.getFriendPersonas() } } ?: return + val arr = try { JSONArray(json) } catch (_: Exception) { return } + if (arr.length() == 0) return + val byId = LinkedHashMap(current.size) + for (e in current) byId[e.steamId] = e + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + val sid = o.optLong("sid", 0L) + if (sid == 0L || !byId.containsKey(sid)) continue + byId[sid] = SteamFriendEntry( + steamId = sid, + name = o.optString("name", ""), + state = EPersonaState.from(o.optInt("state", 0)) ?: EPersonaState.Offline, + gameAppId = o.optInt("app", 0), + gameName = o.optString("gameName", ""), + avatarHash = o.optString("avatarHash", ""), + connectString = o.optString("connect", ""), + ) + } + for ((id, entry) in byId.toList()) { + if (entry.isOnline && entry.gameName.isBlank() && entry.gameAppId > 0) { + val name = resolveGameName(entry.gameAppId) + if (name.isNotBlank()) byId[id] = entry.copy(gameName = name) + } + } + svc._friendsList.value = byId.values.toList() + } + + private val gameNameCache = java.util.concurrent.ConcurrentHashMap() + + // appId -> display name: cached, local app DB first, then the public store API. + suspend fun resolveGameName(appId: Int): String { + if (appId <= 0) return "" + gameNameCache[appId]?.let { return it } + getAppInfoOf(appId)?.name?.takeIf { it.isNotBlank() }?.let { + gameNameCache[appId] = it + return it + } + val fetched = withContext(Dispatchers.IO) { + runCatching { + val conn = java.net.URL( + "https://store.steampowered.com/api/appdetails?appids=$appId&filters=basic", + ).openConnection() as java.net.HttpURLConnection + conn.connectTimeout = 8000 + conn.readTimeout = 8000 + conn.setRequestProperty("User-Agent", "Mozilla/5.0") + val text = conn.inputStream.bufferedReader().use { it.readText() } + val o = JSONObject(text).optJSONObject(appId.toString()) + if (o?.optBoolean("success") == true) o.optJSONObject("data")?.optString("name").orEmpty() else "" + }.getOrDefault("") + } + if (fetched.isNotBlank()) gameNameCache[appId] = fetched + return fetched + } + + // Send a 1-to-1 text message to a friend. Returns true on success. + suspend fun sendChatMessage(steamId: Long, text: String): Boolean { + if (text.isBlank()) return false + val resp = withContext(Dispatchers.IO) { withWnSession { s -> s.sendFriendMessage(steamId, text) } } + return !resp.isNullOrBlank() + } + + // Upload an image to Steam chat UGC and send it to a friend; returns the URL or null. + suspend fun sendChatImage(steamId: Long, bytes: ByteArray, fileName: String): String? { + if (bytes.isEmpty()) return null + val refreshToken = com.winlator.cmod.feature.stores.steam.utils.PrefManager.refreshToken + if (refreshToken.isBlank()) return null + return withContext(Dispatchers.IO) { + withWnSession { s -> s.sendChatImage(steamId, refreshToken, bytes, fileName) } + } + } + + // Load conversation history with a friend, ordered oldest-first. + suspend fun loadChatHistory(steamId: Long, count: Int = 50): List { + val json = withContext(Dispatchers.IO) { withWnSession { s -> s.getRecentMessages(steamId, count) } } ?: "[]" + val arr = try { JSONArray(json) } catch (_: Exception) { JSONArray() } + val out = ArrayList(arr.length()) + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + out.add( + com.winlator.cmod.feature.stores.steam.data.SteamChatMessage( + fromSelf = o.optBoolean("fromSelf", false), + text = o.optString("message", ""), + timestamp = o.optInt("timestamp", 0), + ordinal = o.optInt("ordinal", 0), + ) + ) + } + out.sortWith(compareBy({ it.timestamp }, { it.ordinal })) + return out + } + + // Drain queued incoming messages, grouped by friend steamId. + suspend fun drainIncomingMessages(): Map> { + val json = withWnSession { s -> s.drainFriendMessages() } ?: "[]" + val arr = try { JSONArray(json) } catch (_: Exception) { JSONArray() } + if (arr.length() == 0) return emptyMap() + val grouped = LinkedHashMap>() + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + val fid = o.optLong("friendId", 0L) + if (fid == 0L) continue + grouped.getOrPut(fid) { ArrayList() }.add( + com.winlator.cmod.feature.stores.steam.data.SteamChatMessage( + fromSelf = o.optBoolean("fromSelf", false), + text = o.optString("message", ""), + timestamp = o.optInt("timestamp", 0), + ordinal = o.optInt("ordinal", 0), + ) + ) + } + return grouped + } + + fun setActiveConversation(steamId: Long) { + if (steamId == 0L) return + activeConversations.merge(steamId, 1) { a, b -> a + b } + touchRecentChat(steamId) + clearUnread(steamId) + runCatching { notificationHelper.cancelChatNotification(steamId) } + } + + private fun touchRecentChat(friendId: Long) { + if (friendId == 0L) return + _recentChats.update { it + (friendId to System.currentTimeMillis()) } + } + + fun sendChatImageAsync(friendId: Long, bytes: ByteArray, fileName: String) { + if (friendId == 0L || bytes.isEmpty()) return + scope.launch { sendChatImage(friendId, bytes, fileName) } + } + + fun clearActiveConversation(steamId: Long) { + if (steamId == 0L) return + activeConversations.compute(steamId) { _, v -> if (v == null || v <= 1) null else v - 1 } + } + + private fun isActiveConversation(steamId: Long): Boolean = activeConversations.containsKey(steamId) + + fun clearUnread(steamId: Long) { + _unreadCounts.update { if (it.containsKey(steamId)) it - steamId else it } + } + + private fun continuousIncomingMessagePoller(): Job = + scope.launch { + while (isActive && isLoggedIn) { + delay(1000L) + val grouped = runCatching { drainIncomingMessages() }.getOrNull() + if (grouped.isNullOrEmpty()) continue + dispatchIncomingChat(grouped) + } + } + + private fun dispatchIncomingChat( + grouped: Map>, + ) { + val friends = _friendsList.value.associateBy { it.steamId } + val suppressed = GameSessionState.inGame && !PrefManager.chatInGameEnabled + for ((friendId, messages) in grouped) { + for (m in messages) _incomingChat.tryEmit(friendId to m) + val fromFriend = messages.filter { !it.fromSelf && it.text.isNotBlank() } + if (fromFriend.isEmpty()) continue + touchRecentChat(friendId) + if (isActiveConversation(friendId)) continue + _unreadCounts.update { it + (friendId to ((it[friendId] ?: 0) + fromFriend.size)) } + if (suppressed) continue + val name = friends[friendId]?.name?.ifBlank { friendId.toString() } ?: friendId.toString() + val preview = chatPreview(fromFriend.last().text) + if (PrefManager.chatNotificationsEnabled) { + runCatching { notificationHelper.notifyChatMessage(friendId, name, preview) } + } + if (PrefManager.chatHeadsEnabled) { + runCatching { + com.winlator.cmod.feature.stores.steam.chat.ChatOverlayService.onIncoming(this, friendId) + } + } + } + } + + private fun chatPreview(text: String): String { + val t = text.trim() + return if (t.startsWith("[img") || t.contains("steamusercontent.com")) { + getString(com.winlator.cmod.R.string.steam_chat_image) + } else { + t + } + } + /** * Request changes for apps and packages since a given change number. * Checks every [PICS_CHANGE_CHECK_DELAY] seconds. diff --git a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt index 64fe42af2..fef57ca0f 100644 --- a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt +++ b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt @@ -198,6 +198,7 @@ fun WnKeyValue.generateSteamApp(): SteamApp = workingDir = it["workingdir"].value?.replace('\\', '/').orEmpty(), description = it["description"].value.orEmpty(), type = it["type"].value.orEmpty(), + arguments = it["arguments"].value.orEmpty(), configOS = OS.from(it["config"]["oslist"].value), configArch = OSArch.from(it["config"]["osarch"].value), ) diff --git a/app/src/main/feature/stores/steam/utils/PrefManager.kt b/app/src/main/feature/stores/steam/utils/PrefManager.kt index a798b2a2a..8dd816c37 100644 --- a/app/src/main/feature/stores/steam/utils/PrefManager.kt +++ b/app/src/main/feature/stores/steam/utils/PrefManager.kt @@ -324,6 +324,30 @@ object PrefManager { setString("gog_download_folder", value) } + var chatNotificationsEnabled: Boolean + get() = getBoolean("chat_notifications_enabled", true) + set(value) { + setBoolean("chat_notifications_enabled", value) + } + + var chatHeadsEnabled: Boolean + get() = getBoolean("chat_heads_enabled", false) + set(value) { + setBoolean("chat_heads_enabled", value) + } + + var chatInGameEnabled: Boolean + get() = getBoolean("chat_in_game_enabled", false) + set(value) { + setBoolean("chat_in_game_enabled", value) + } + + var chatHeadsAutoHide: Boolean + get() = getBoolean("chat_heads_auto_hide", false) + set(value) { + setBoolean("chat_heads_auto_hide", value) + } + fun clearAuthTokens() { requirePrefs().edit().apply { remove("user_name") diff --git a/app/src/main/feature/stores/steam/utils/SteamLaunchOptions.kt b/app/src/main/feature/stores/steam/utils/SteamLaunchOptions.kt new file mode 100644 index 000000000..6a35e4e85 --- /dev/null +++ b/app/src/main/feature/stores/steam/utils/SteamLaunchOptions.kt @@ -0,0 +1,53 @@ +package com.winlator.cmod.feature.stores.steam.utils + +/** Parses Steam-style launch options: KEY=VALUE before %command% become env vars; args after become game args. */ +object SteamLaunchOptions { + private val ENV_KEY = Regex("[A-Za-z_][A-Za-z0-9_]*") + private const val COMMAND = "%command%" + + data class Parsed(val env: LinkedHashMap, val gameArgs: String) + + @JvmStatic + fun parse(execArgs: String?): Parsed { + val env = LinkedHashMap() + val raw = execArgs?.trim().orEmpty() + if (raw.isEmpty()) return Parsed(env, "") + val idx = raw.indexOf(COMMAND) + if (idx < 0) return Parsed(env, raw) + val before = raw.substring(0, idx).trim() + val after = raw.substring(idx + COMMAND.length).trim() + for (token in tokenize(before)) { + val eq = token.indexOf('=') + if (eq <= 0) continue + val key = token.substring(0, eq) + if (ENV_KEY.matches(key)) env[key] = token.substring(eq + 1) + } + return Parsed(env, after) + } + + /** Command-line arguments to pass to the game executable. */ + @JvmStatic + fun gameArgs(execArgs: String?): String = parse(execArgs).gameArgs + + /** Environment variables to apply to the game process. */ + @JvmStatic + fun parseEnvVars(execArgs: String?): Map = parse(execArgs).env + + private fun tokenize(s: String): List { + val out = ArrayList() + val sb = StringBuilder() + var quote: Char? = null + for (c in s) { + when { + quote != null -> if (c == quote) quote = null else sb.append(c) + c == '"' || c == '\'' -> quote = c + c.isWhitespace() -> if (sb.isNotEmpty()) { + out.add(sb.toString()); sb.setLength(0) + } + else -> sb.append(c) + } + } + if (sb.isNotEmpty()) out.add(sb.toString()) + return out + } +} diff --git a/app/src/main/feature/stores/steam/utils/SteamUtils.kt b/app/src/main/feature/stores/steam/utils/SteamUtils.kt index 0b022ffa0..a854be1c7 100644 --- a/app/src/main/feature/stores/steam/utils/SteamUtils.kt +++ b/app/src/main/feature/stores/steam/utils/SteamUtils.kt @@ -1178,14 +1178,15 @@ object SteamUtils { val dlcApps = SteamService.getDownloadableDlcAppsOf(appId) val hiddenDlcApps = SteamService.getHiddenDlcAppsOf(appId) val appendedDlcIds = mutableListOf() + val selectedBranch = SteamService.resolveSelectedBetaName(appId).ifBlank { "public" } val appIniContent = buildString { - // [app::general] — make Steam_Apps::GetCurrentBetaName() - // deterministic; WinNative always installs the public branch. + // [app::general] — communicate the active branch to gbe_fork so + // GetCurrentBetaName() and GetAppBuildId() return correct values. appendLine("[app::general]") - appendLine("is_beta_branch=0") - appendLine("branch_name=public") + appendLine("is_beta_branch=${if (selectedBranch.equals("public", ignoreCase = true)) 0 else 1}") + appendLine("branch_name=$selectedBranch") appendLine() appendLine("[app::dlcs]") appendLine("unlock_all=0") @@ -1399,15 +1400,39 @@ object SteamUtils { } } + /** + * Combines a launch option's own arguments (appinfo `config.launch` entry, persisted + * as the shortcut's `launch_exe_args` extra) with the user's custom args. Honors + * Steam's `%command%` placeholder in the custom args: the option args stay attached + * to the command itself so a user wrapper like `FOO=1 %command% -windowed` keeps + * working. + */ + @JvmStatic + fun combineSteamLaunchArgs( + selectedArgs: String?, + customArgs: String?, + ): String { + val selected = selectedArgs.orEmpty().trim() + val custom = customArgs.orEmpty().trim() + if (selected.isEmpty()) return custom + if (custom.isEmpty()) return selected + return if (custom.contains("%command%")) { + custom.replace("%command%", "%command% $selected") + } else { + "$selected $custom" + } + } + @JvmStatic fun updateOrModifyLocalConfig( imageFs: ImageFs, container: Container, appId: String, steamUserId64: String, + selectedLaunchArgs: String = "", ) { try { - val exeCommandLine = container.execArgs + val exeCommandLine = combineSteamLaunchArgs(selectedLaunchArgs, container.execArgs) com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient .setLaunchCommandLine(exeCommandLine) diff --git a/app/src/main/feature/stores/steam/wnsteam/WnSteamSession.kt b/app/src/main/feature/stores/steam/wnsteam/WnSteamSession.kt index 4730048ac..a2fea03fa 100644 --- a/app/src/main/feature/stores/steam/wnsteam/WnSteamSession.kt +++ b/app/src/main/feature/stores/steam/wnsteam/WnSteamSession.kt @@ -557,6 +557,34 @@ class WnSteamSession : AutoCloseable { catch (_: UnsatisfiedLinkError) { "[]" } } + // Blocking: send a 1-to-1 friend message; returns response JSON or null. + fun sendFriendMessage(steamId: Long, message: String): String? { + val h = nativeHandle.get(); if (h == 0L) return null + return try { nativeSendFriendMessage(h, steamId, message) } + catch (_: UnsatisfiedLinkError) { null } + } + + // Blocking: recent message history for a conversation; JSON array. + fun getRecentMessages(steamId: Long, count: Int = 50): String { + val h = nativeHandle.get(); if (h == 0L) return "[]" + return try { nativeGetRecentMessages(h, steamId, count) ?: "[]" } + catch (_: UnsatisfiedLinkError) { "[]" } + } + + // Drains queued incoming-message notifications; JSON array. + fun drainFriendMessages(): String { + val h = nativeHandle.get(); if (h == 0L) return "[]" + return try { nativeDrainFriendMessages(h) ?: "[]" } + catch (_: UnsatisfiedLinkError) { "[]" } + } + + // Blocking: upload an image to Steam chat UGC and send it to a friend; returns the URL or null. + fun sendChatImage(steamId: Long, refreshToken: String, bytes: ByteArray, fileName: String): String? { + val h = nativeHandle.get(); if (h == 0L) return null + return try { nativeSendChatImage(h, steamId, refreshToken, bytes, fileName) } + catch (_: UnsatisfiedLinkError) { null } + } + fun getOwnedGames(steamId: Long): String? { val h = nativeHandle.get(); if (h == 0L) return null return nativeGetOwnedGames(h, steamId) @@ -744,6 +772,10 @@ class WnSteamSession : AutoCloseable { @JvmStatic private external fun nativeGetLicenseList(handle: Long): String? @JvmStatic private external fun nativeGetFriendsList(handle: Long): LongArray @JvmStatic private external fun nativeGetFriendPersonas(handle: Long): String? + @JvmStatic private external fun nativeSendFriendMessage(handle: Long, steamId: Long, message: String): String? + @JvmStatic private external fun nativeGetRecentMessages(handle: Long, steamId: Long, count: Int): String? + @JvmStatic private external fun nativeDrainFriendMessages(handle: Long): String? + @JvmStatic private external fun nativeSendChatImage(handle: Long, steamId: Long, refreshToken: String, image: ByteArray, fileName: String): String? @JvmStatic private external fun nativeGetOwnedGames( handle: Long, steamId: Long): String? @JvmStatic private external fun nativeSignalAppLaunchIntent(handle: Long, appId: Int, clientId: Long, machineName: String, ignorePending: Boolean, osType: Int): String? diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 4da69e5b1..45ee0479d 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1319,4 +1319,51 @@ Installeret sti: Indlæser Workshop-elementer Søg i Workshop-elementer Mislykket + Snooze + Optaget + Vil bytte + Vil spille + Venner — %1$d online + I spil (%1$d) + Online (%1$d) + Offline (%1$d) + Ingen venner indlæst endnu. + Dig + I spil + Deltag + (billedet kunne ikke sendes) + Ingen beskeder endnu. Sig hej! + Uploader billede… + Send billede + Besked… + Send + Billede + Tilbage + Præstationer + Ingen præstationer fundet for dette spil,\neller log ind på Steam for at indlæse dem. + Skjult præstation + Bliv ved med at spille for at afsløre denne præstation. + Låst op %1$s + Låst op + Deltager hos %1$s i %2$s… + Installer %1$s for at deltage hos %2$s + Du ejer ikke %1$s + (ikke sendt) + spillet + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1fbd2f601..d8858dd1d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1319,4 +1319,51 @@ Installierter Pfad: Workshop-Elemente werden geladen Workshop-Elemente suchen Fehlgeschlagen + Schlummern + Beschäftigt + Sucht Tauschpartner + Sucht Mitspieler + Freunde — %1$d online + Im Spiel (%1$d) + Online (%1$d) + Offline (%1$d) + Noch keine Freunde geladen. + Du + Im Spiel + Beitreten + (Bild konnte nicht gesendet werden) + Noch keine Nachrichten. Sag Hallo! + Bild wird hochgeladen… + Bild senden + Nachricht… + Senden + Bild + Zurück + Errungenschaften + Keine Errungenschaften für dieses Spiel gefunden,\noder melde dich bei Steam an, um sie zu laden. + Versteckte Errungenschaft + Spiele weiter, um diese Errungenschaft freizuschalten. + Freigeschaltet %1$s + Freigeschaltet + %1$s in %2$s beitreten… + Installiere %1$s, um %2$s beizutreten + Du besitzt %1$s nicht + (nicht gesendet) + das Spiel + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ee0d732cb..c02e81004 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1319,5 +1319,52 @@ Ruta instalada: Cargando elementos de Workshop Buscar elementos de Workshop Fallido + Inactivo + Ocupado + Buscando intercambio + Buscando jugar + Amigos — %1$d en línea + En juego (%1$d) + En línea (%1$d) + Desconectado (%1$d) + Aún no se han cargado amigos. + + En juego + Unirse + (no se pudo enviar la imagen) + Aún no hay mensajes. ¡Saluda! + Subiendo imagen… + Enviar imagen + Mensaje… + Enviar + Imagen + Atrás + Logros + No se encontraron logros para este juego,\no inicia sesión en Steam para cargarlos. + Logro oculto + Sigue jugando para revelar este logro. + Desbloqueado %1$s + Desbloqueado + Uniéndose a %1$s en %2$s… + Instala %1$s para unirte a %2$s + No tienes %1$s + (no enviado) + el juego + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6e4ee317e..e627967c6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1319,5 +1319,52 @@ Chemin installé : Chargement des éléments du Workshop Rechercher des éléments du Workshop Échec + En veille + Occupé + Cherche à échanger + Cherche à jouer + Amis — %1$d en ligne + En jeu (%1$d) + En ligne (%1$d) + Hors ligne (%1$d) + Aucun ami chargé pour le moment. + Vous + En jeu + Rejoindre + (échec de l\'envoi de l\'image) + Aucun message. Dites bonjour ! + Envoi de l\'image… + Envoyer une image + Message… + Envoyer + Image + Retour + Succès + Aucun succès trouvé pour ce jeu,\nou connectez-vous à Steam pour les charger. + Succès caché + Continuez à jouer pour révéler ce succès. + Débloqué %1$s + Débloqué + Rejoindre %1$s dans %2$s… + Installez %1$s pour rejoindre %2$s + Vous ne possédez pas %1$s + (non envoyé) + le jeu + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index f48fb41a5..155d9b193 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1256,4 +1256,51 @@ Workshop आइटम लोड हो रहे हैं Workshop आइटम खोजें विफल + स्नूज़ + व्यस्त + व्यापार के इच्छुक + खेलने के इच्छुक + मित्र — %1$d ऑनलाइन + खेल में (%1$d) + ऑनलाइन (%1$d) + ऑफ़लाइन (%1$d) + अभी तक कोई मित्र लोड नहीं हुआ। + आप + खेल में + शामिल हों + (छवि भेजने में विफल) + अभी तक कोई संदेश नहीं। नमस्ते कहें! + छवि अपलोड हो रही है… + छवि भेजें + संदेश… + भेजें + छवि + वापस + उपलब्धियाँ + इस गेम के लिए कोई उपलब्धि नहीं मिली,\nया उन्हें लोड करने के लिए Steam में साइन इन करें। + छिपी हुई उपलब्धि + इस उपलब्धि को प्रकट करने के लिए खेलते रहें। + अनलॉक किया %1$s + अनलॉक किया + %2$s में %1$s से जुड़ रहे हैं… + %2$s से जुड़ने के लिए %1$s इंस्टॉल करें + आपके पास %1$s नहीं है + (नहीं भेजा गया) + गेम + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9ef2cf556..12fb9cfe7 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1319,5 +1319,52 @@ Percorso installato: Caricamento elementi Workshop Cerca elementi Workshop Non riuscito + Inattivo + Occupato + In cerca di scambi + In cerca di gioco + Amici — %1$d online + In gioco (%1$d) + Online (%1$d) + Offline (%1$d) + Ancora nessun amico caricato. + Tu + In gioco + Unisciti + (invio immagine non riuscito) + Ancora nessun messaggio. Saluta! + Caricamento immagine… + Invia immagine + Messaggio… + Invia + Immagine + Indietro + Obiettivi + Nessun obiettivo trovato per questo gioco,\noppure accedi a Steam per caricarli. + Obiettivo nascosto + Continua a giocare per rivelare questo obiettivo. + Sbloccato %1$s + Sbloccato + Partecipazione a %1$s in %2$s… + Installa %1$s per unirti a %2$s + Non possiedi %1$s + (non inviato) + il gioco + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 479ffd6ea..7caae11dd 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1320,5 +1320,52 @@ Workshop 항목 로드 중 Workshop 항목 검색 실패 + 잠시 자리 비움 + 다른 용무 중 + 교환 희망 + 게임 희망 + 친구 — %1$d명 온라인 + 게임 중 (%1$d) + 온라인 (%1$d) + 오프라인 (%1$d) + 아직 불러온 친구가 없습니다. + + 게임 중 + 참가 + (이미지 전송 실패) + 아직 메시지가 없습니다. 인사해 보세요! + 이미지 업로드 중… + 이미지 보내기 + 메시지… + 보내기 + 이미지 + 뒤로 + 도전 과제 + 이 게임의 도전 과제를 찾을 수 없습니다.\n또는 Steam에 로그인하여 불러오세요. + 숨겨진 도전 과제 + 이 도전 과제를 확인하려면 계속 플레이하세요. + %1$s 달성 + 달성 + %2$s에서 %1$s님 참가 중… + %2$s에 참가하려면 %1$s을(를) 설치하세요 + %1$s을(를) 보유하고 있지 않습니다 + (전송되지 않음) + 게임 + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 843a3895f..55b118e5a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1325,5 +1325,52 @@ Zainstalowana ścieżka: Ładowanie elementów Workshop Szukaj elementów Workshop Niepowodzenie + Drzemka + Zajęty + Chce handlować + Chce grać + Znajomi — %1$d online + W grze (%1$d) + Online (%1$d) + Offline (%1$d) + Nie wczytano jeszcze znajomych. + Ty + W grze + Dołącz + (nie udało się wysłać obrazu) + Brak wiadomości. Przywitaj się! + Wysyłanie obrazu… + Wyślij obraz + Wiadomość… + Wyślij + Obraz + Wstecz + Osiągnięcia + Nie znaleziono osiągnięć dla tej gry,\nlub zaloguj się do Steam, aby je wczytać. + Ukryte osiągnięcie + Graj dalej, aby odkryć to osiągnięcie. + Odblokowano %1$s + Odblokowano + Dołączanie do %1$s w %2$s… + Zainstaluj %1$s, aby dołączyć do %2$s + Nie posiadasz %1$s + (nie wysłano) + grę + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 2626e308e..31e85b71f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1319,5 +1319,52 @@ Caminho instalado: Carregando itens do Workshop Buscar itens do Workshop Falhou + Soneca + Ocupado + Quer trocar + Quer jogar + Amigos — %1$d online + Em jogo (%1$d) + Online (%1$d) + Offline (%1$d) + Nenhum amigo carregado ainda. + Você + Em jogo + Entrar + (falha ao enviar imagem) + Sem mensagens ainda. Diga oi! + Enviando imagem… + Enviar imagem + Mensagem… + Enviar + Imagem + Voltar + Conquistas + Nenhuma conquista encontrada para este jogo,\nou entre no Steam para carregá-las. + Conquista oculta + Continue jogando para revelar esta conquista. + Desbloqueada %1$s + Desbloqueada + Entrando com %1$s em %2$s… + Instale %1$s para entrar com %2$s + Você não possui %1$s + (não enviado) + o jogo + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 3b997abae..21843c3f3 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1319,5 +1319,52 @@ Cale instalata: Se încarcă elementele Workshop Caută elemente Workshop Eșuat + Inactiv + Ocupat + Caută schimburi + Caută să joace + Prieteni — %1$d online + În joc (%1$d) + Online (%1$d) + Offline (%1$d) + Niciun prieten încărcat încă. + Tu + În joc + Alătură-te + (imaginea nu a putut fi trimisă) + Niciun mesaj încă. Salută! + Se încarcă imaginea… + Trimite imagine + Mesaj… + Trimite + Imagine + Înapoi + Realizări + Nicio realizare găsită pentru acest joc,\nsau conectează-te la Steam pentru a le încărca. + Realizare ascunsă + Continuă să joci pentru a dezvălui această realizare. + Deblocat %1$s + Deblocat + Te alături lui %1$s în %2$s… + Instalează %1$s pentru a te alătura lui %2$s + Nu deții %1$s + (netrimis) + jocul + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 30f9c3d6f..58b63fe7d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1225,4 +1225,51 @@ Загрузка элементов Workshop Поиск элементов Workshop Ошибка + Спящий режим + Занят + Хочет обмен + Хочет играть + Друзья — %1$d в сети + В игре (%1$d) + В сети (%1$d) + Не в сети (%1$d) + Друзья ещё не загружены. + Вы + В игре + Войти + (не удалось отправить изображение) + Сообщений пока нет. Поздоровайтесь! + Отправка изображения… + Отправить изображение + Сообщение… + Отправить + Изображение + Назад + Достижения + Достижения для этой игры не найдены,\nили войдите в Steam, чтобы загрузить их. + Скрытое достижение + Продолжайте играть, чтобы открыть это достижение. + Получено %1$s + Получено + Подключение к %1$s в %2$s… + Установите %1$s, чтобы подключиться к %2$s + У вас нет %1$s + (не отправлено) + игру + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index a6acd05f7..d232b5531 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1325,5 +1325,52 @@ Завантаження елементів Workshop Пошук елементів Workshop Помилка + Сплячий режим + Зайнятий + Хоче обмін + Хоче грати + Друзі — %1$d у мережі + У грі (%1$d) + У мережі (%1$d) + Не в мережі (%1$d) + Друзів ще не завантажено. + Ви + У грі + Приєднатися + (не вдалося надіслати зображення) + Повідомлень ще немає. Привітайтеся! + Завантаження зображення… + Надіслати зображення + Повідомлення… + Надіслати + Зображення + Назад + Досягнення + Досягнень для цієї гри не знайдено,\nабо увійдіть у Steam, щоб завантажити їх. + Приховане досягнення + Продовжуйте грати, щоб відкрити це досягнення. + Отримано %1$s + Отримано + Приєднання до %1$s у %2$s… + Встановіть %1$s, щоб приєднатися до %2$s + У вас немає %1$s + (не надіслано) + гру + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8b0666632..23447acb9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1319,5 +1319,52 @@ 正在加载 Workshop 项目 搜索 Workshop 项目 失败 + 小憩 + 忙碌 + 想交易 + 想一起玩 + 好友 — %1$d 在线 + 游戏中 (%1$d) + 在线 (%1$d) + 离线 (%1$d) + 尚未加载好友。 + + 游戏中 + 加入 + (图片发送失败) + 还没有消息。打个招呼吧! + 正在上传图片… + 发送图片 + 消息… + 发送 + 图片 + 返回 + 成就 + 未找到此游戏的成就,\n或登录 Steam 以加载它们。 + 隐藏成就 + 继续游戏以揭示此成就。 + 已解锁 %1$s + 已解锁 + 正在加入 %1$s 的 %2$s… + 安装 %1$s 以加入 %2$s + 你未拥有 %1$s + (未发送) + 游戏 + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index d0a881da4..0650b4fba 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1319,5 +1319,52 @@ 正在載入 Workshop 項目 搜尋 Workshop 項目 失敗 + 小憩 + 忙碌 + 想交易 + 想一起玩 + 好友 — %1$d 在線 + 遊戲中 (%1$d) + 在線 (%1$d) + 離線 (%1$d) + 尚未載入好友。 + + 遊戲中 + 加入 + (圖片傳送失敗) + 還沒有訊息。打個招呼吧! + 正在上傳圖片… + 傳送圖片 + 訊息… + 傳送 + 圖片 + 返回 + 成就 + 找不到此遊戲的成就,\n或登入 Steam 以載入它們。 + 隱藏成就 + 繼續遊玩以揭示此成就。 + 已解鎖 %1$s + 已解鎖 + 正在加入 %1$s 的 %2$s… + 安裝 %1$s 以加入 %2$s + 你未擁有 %1$s + (未傳送) + 遊戲 + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e02e7ea12..dc9d642e2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,10 @@ Update check failed Update Workshop + Launch Options + Couldn\'t save launch option + Beta Branch + Couldn\'t save beta branch Verify Files Verifying %1$s — check the Downloads tab Steam options @@ -1326,4 +1330,51 @@ Installed path: Loading Workshop items Search Workshop items Failed + Snooze + Busy + Looking to Trade + Looking to Play + Friends — %1$d online + In-Game (%1$d) + Online (%1$d) + Offline (%1$d) + No friends loaded yet. + You + In game + Join + (image failed to send) + No messages yet. Say hi! + Uploading image… + Send image + Message… + Send + Image + Back + Achievements + No achievements found for this game,\nor sign in to Steam to load them. + Hidden achievement + Keep playing to reveal this achievement. + Unlocked %1$s + Unlocked + Joining %1$s in %2$s… + Install %1$s to join %2$s + You don\'t own %1$s + (not sent) + the game + Settings + Chat Settings + Chat Notifications + Show a notification when you receive a message + Chat Heads + Floating bubbles to read and reply to messages + Enable Chat in Game + Receive and send messages while playing + Allow display over other apps to use chat heads + Messages + Recent + Online + New message + Auto-Hide + Fade the chat head after 10s of inactivity + Friends diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 7eddeb579..7c573f072 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -2240,6 +2240,7 @@ private int[] getCapturedPointerDelta(MotionEvent event) { @Override public void onResume() { super.onResume(); + com.winlator.cmod.feature.stores.steam.service.GameSessionState.setInGame(this, true); applyPreferredRefreshRate(); registerGyroSensorIfEnabled(); @@ -3604,6 +3605,7 @@ private static boolean wnLauncherLogContains(File log, String marker) { @Override protected void onDestroy() { activityDestroyed.set(true); + com.winlator.cmod.feature.stores.steam.service.GameSessionState.setInGame(this, false); unregisterDisplayChangeListener(); if (preloaderDialog != null) { preloaderDialog.close(); @@ -5557,6 +5559,16 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { "' effective='" + effectiveCustomEnvVars + "'"); envVars.putAll(effectiveCustomEnvVars); + // Steam-style launch options: KEY=VALUE tokens before %command% become env vars. + String launchOptsForEnv = shortcut != null + ? getShortcutSetting("execArgs", container.getExecArgs()) + : container.getExecArgs(); + java.util.Map steamOptEnv = + com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.parseEnvVars(launchOptsForEnv); + for (java.util.Map.Entry e : steamOptEnv.entrySet()) { + envVars.put(e.getKey(), e.getValue()); + } + normalizeSyncEnvVars(envVars); ArrayList bindingPaths = new ArrayList<>(); @@ -6887,7 +6899,9 @@ private String getWineStartCommand(GuestProgramLauncherComponent launcherCompone int appId = Integer.parseInt(shortcut.getExtra("app_id")); // Reset per launch; set below once the launch exe is resolved. wnSteamDirectExeOverride = false; - String steamExtraArgs = shortcut.getSettingExtra("execArgs", container.getExecArgs()); + String steamExtraArgs = appendSteamJoinConnect( + com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.gameArgs( + combineWithSelectedLaunchArgs(shortcut.getSettingExtra("execArgs", container.getExecArgs())))); steamExtraArgs = (steamExtraArgs != null && !steamExtraArgs.isEmpty()) ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); @@ -7274,7 +7288,9 @@ private void writeColdClientIniDirect(int appId, String gameDirName, String rela } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = perGameExecArgs != null ? perGameExecArgs : ""; + String exeCommandLine = appendSteamJoinConnect( + com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.gameArgs( + combineWithSelectedLaunchArgs(perGameExecArgs))); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7285,6 +7301,15 @@ private void writeColdClientIniDirect(int appId, String gameDirName, String rela + " AppId=" + appId + " runtimePatcher=" + runtimePatcher); } + // Appends a friend's join connect string to the game's launch arguments. + private String appendSteamJoinConnect(String args) { + String joinConnect = getIntent().getStringExtra("steam_join_connect"); + if (joinConnect == null || joinConnect.trim().isEmpty()) return args != null ? args : ""; + joinConnect = joinConnect.trim(); + if (args == null || args.trim().isEmpty()) return joinConnect; + return args.trim() + " " + joinConnect; + } + private String buildColdClientIni(int appId, String exePath, String exeRunDir, String exeCommandLine, boolean runtimePatcher) { StringBuilder sb = new StringBuilder(1024); @@ -7350,7 +7375,9 @@ private void writeColdClientIniForLaunch(int appId, String gameInstallPath, Stri } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = perGameExecArgs != null ? perGameExecArgs : ""; + String exeCommandLine = appendSteamJoinConnect( + com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.gameArgs( + combineWithSelectedLaunchArgs(perGameExecArgs))); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7475,6 +7502,16 @@ private static String exeBaseName(String path) { return normalized.trim(); } + /** + * The selected Steam launch option's own arguments (shortcut extra + * {@code launch_exe_args}) merged with the given custom args — the single + * resolver every Steam launch channel uses for its effective command line. + */ + private String combineWithSelectedLaunchArgs(String customArgs) { + return SteamUtils.combineSteamLaunchArgs( + shortcut != null ? shortcut.getExtra("launch_exe_args") : "", customArgs); + } + private String resolveRelativeGameExe(int appId, String gameInstPath) { // Per-game launch_exe_path wins over the shared container cache. String shortcutExePath = resolveShortcutSteamExecutablePath(gameInstPath); @@ -9027,10 +9064,17 @@ private void setupSteamEnvironment(int appId, File gameDir) { // Stamp-cache the registry edits + userdata reconcile + local-config // edit so warm launches of the same game in the same container skip // the per-launch file-copy / VDF-parse work. Stamp key is - // appId|userDataId — change either and the work re-runs. + // appId|userDataId|launchOptionsHash — change any and the work re-runs. File steamEnvStamp = new File(winePrefix, ".wine/drive_c/.wn-steamenv-" + appId + "-" + steamUserDataId + ".stamp"); - String expectedStamp = "v1|" + appId + "|" + steamUserDataId; + String selectedLaunchArgs = shortcut != null ? shortcut.getExtra("launch_exe_args") : ""; + // The effective LaunchOptions line is part of the stamp so a changed + // launch-option selection (or custom args) re-runs the localconfig edit. + // Raw string, not a hash: equality must be exact (no collisions). + String effectiveLaunchOptions = + SteamUtils.combineSteamLaunchArgs(selectedLaunchArgs, container.getExecArgs()); + String expectedStamp = "v2|" + appId + "|" + steamUserDataId + + "|" + effectiveLaunchOptions; String existingStamp = steamEnvStamp.exists() ? FileUtils.readString(steamEnvStamp).trim() : ""; boolean steamEnvWarm = expectedStamp.equals(existingStamp); @@ -9045,7 +9089,8 @@ private void setupSteamEnvironment(int appId, File gameDir) { skipFirstTimeSteamSetup(winePrefix); reconcileSteamUserdata(steamDir, steamUserDataId, steamId64); - SteamUtils.updateOrModifyLocalConfig(imageFs, container, String.valueOf(appId), steamUserDataId); + SteamUtils.updateOrModifyLocalConfig(imageFs, container, String.valueOf(appId), steamUserDataId, + selectedLaunchArgs); setupLightweightSteamConfig(steamDir, steamUserDataId); try { @@ -9055,6 +9100,10 @@ private void setupSteamEnvironment(int appId, File gameDir) { "Failed to write steam-env stamp at " + steamEnvStamp.getPath(), e); } } else { + // localconfig.vdf is already up to date, but the pushed launch command + // line is per-process native state — re-push it on warm launches too. + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient.INSTANCE + .setLaunchCommandLine(effectiveLaunchOptions); Log.d("XServerDisplayActivity", "Steam env warm-cache hit (appId=" + appId + ", userId=" + steamUserDataId + ") — skipping reconcile + autoLogin"); diff --git a/app/src/main/shared/android/NotificationHelper.kt b/app/src/main/shared/android/NotificationHelper.kt index e3105288d..99e230132 100644 --- a/app/src/main/shared/android/NotificationHelper.kt +++ b/app/src/main/shared/android/NotificationHelper.kt @@ -25,7 +25,11 @@ class NotificationHelper private const val CHANNEL_NAME = "WinNative Foreground Service" private const val NOTIFICATION_ID = 1 + private const val CHAT_CHANNEL_ID = "winnative_steam_chat" + private const val CHAT_CHANNEL_NAME = "Steam Chat" + const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".EXIT" + const val EXTRA_OPEN_CHAT_FRIEND_ID = BuildConfig.APPLICATION_ID + ".OPEN_CHAT_FRIEND_ID" } private val notificationManager: NotificationManager = @@ -47,6 +51,52 @@ class NotificationHelper } notificationManager.createNotificationChannel(channel) + + val chatChannel = + NotificationChannel( + CHAT_CHANNEL_ID, + CHAT_CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Incoming Steam friend messages" + setShowBadge(true) + } + + notificationManager.createNotificationChannel(chatChannel) + } + + private fun chatNotificationId(friendId: Long): Int = 2_000_000 + ((friendId.hashCode() and 0x7FFFFFFF) % 1_000_000) + + fun notifyChatMessage(friendId: Long, sender: String, message: String) { + val intent = + Intent(context, UnifiedActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(EXTRA_OPEN_CHAT_FRIEND_ID, friendId) + } + val pendingIntent = + PendingIntent.getActivity( + context, + friendId.hashCode(), + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val notification = + NotificationCompat + .Builder(context, CHAT_CHANNEL_ID) + .setContentTitle(sender) + .setContentText(message) + .setSmallIcon(R.drawable.ic_notification) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setAutoCancel(true) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .setContentIntent(pendingIntent) + .build() + notificationManager.notify(chatNotificationId(friendId), notification) + } + + fun cancelChatNotification(friendId: Long) { + notificationManager.cancel(chatNotificationId(friendId)) } fun notify(content: String) {