From 42239188cf7fdb73dc702680049294c6f5f9e171 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Fri, 5 Jun 2026 17:57:44 -0400 Subject: [PATCH 01/21] Add Steam Friends, messaging, images, achievements, launch options, join game Steam Friends: - Right swipe-out drawer (Compose M3) mirroring the left drawer, with self status header, avatars, and In-Game/Online/Offline grouping. - Live friend presence: logon sends protocol_version, ChangeStatus with persona_set_by_user/need_persona_response, and subscribes via Chat.RequestFriendPersonaStates so Steam pushes stateful persona updates. - Wire-type-aware CMsgClientPersonaState parser (field 25 arrives as a fixed64 in stateful pushes); has_persona_state/has_game guards keep metadata-only responses from clobbering live state, game name, and app id. - In-game friends show a compact card with a text-height game-art capsule and the resolved game title beside it (title from the local app DB, then the public store API, since Steam omits game_name for Steam apps). - The redundant self-status section was removed from the left filter drawer. Steam Messages: - FriendMessages protobufs (SendMessage, GetRecentMessages, IncomingMessage) plus JNI for send, history, and a drainable incoming queue fed by the FriendMessagesClient.IncomingMessage notification. - Compose M3 chat screen with history, real-time incoming, optimistic send dedup, and avatars. - Image attachments: system photo picker -> Steam chat UGC upload (beginfileupload/PUT/commitfileupload with a minted web access token, file_type=MIME) delivered to the friend. Renders [img], [img src=...], and bare images.steamusercontent.com/ugc image URLs. Steam Achievements: - Trophy button on the game launch screen between Settings and Create Shortcut, opening a Compose M3 achievements screen with icons, names, descriptions, X/Y progress, and unlock dates. - Overlay the user's real unlock state from CMsgClientGetUserStatsResponse achievement blocks onto the schema-derived definitions. Join Game: - Join button on joinable in-game friends launches their game (when owned and installed) with the connect string appended to the Steam launch args (ColdClient ExeCommandLine and the Goldberg arg path). Launch options: - Steam-style %command% launch options: KEY=VALUE tokens before %command% become environment variables for the game process; arguments after %command% are passed to the game. Added a "Steam (%command%)" preset. Localization: - All new user-facing strings extracted to resources and translated across all 15 supported locales (da, de, es, fr, hi, it, ko, pl, pt-BR, ro, ru, uk, zh-CN, zh-TW, plus English). --- .../main/app/shell/LibraryGameLaunchScreen.kt | 10 + app/src/main/app/shell/UnifiedActivity.kt | 301 ++++++------- .../wn-steam-client/rust/src/chat_image.rs | 239 +++++++++++ .../cpp/wn-steam-client/rust/src/cm_client.rs | 157 ++++++- .../wn-steam-client/rust/src/cm_runtime.rs | 8 + .../main/cpp/wn-steam-client/rust/src/emsg.rs | 1 + .../main/cpp/wn-steam-client/rust/src/jni.rs | 190 +++++++++ .../main/cpp/wn-steam-client/rust/src/lib.rs | 1 + .../rust/src/pb/cfriendmessages.rs | 227 ++++++++++ .../rust/src/pb/cmsg_client_change_status.rs | 8 + .../rust/src/pb/cmsg_client_persona.rs | 54 ++- .../cpp/wn-steam-client/rust/src/pb/mod.rs | 1 + app/src/main/feature/library/GameSettings.kt | 7 + .../achievements/SteamAchievementsScreen.kt | 244 +++++++++++ .../stores/steam/data/SteamChatMessage.kt | 8 + .../stores/steam/data/SteamFriendEntry.kt | 35 ++ .../steam/friends/FriendsDrawerContent.kt | 397 ++++++++++++++++++ .../stores/steam/friends/SteamChatScreen.kt | 356 ++++++++++++++++ .../stores/steam/service/SteamService.kt | 190 ++++++++- .../stores/steam/utils/SteamLaunchOptions.kt | 53 +++ .../stores/steam/wnsteam/WnSteamSession.kt | 32 ++ app/src/main/res/values-da/strings.xml | 31 ++ app/src/main/res/values-de/strings.xml | 31 ++ app/src/main/res/values-es/strings.xml | 31 ++ app/src/main/res/values-fr/strings.xml | 31 ++ app/src/main/res/values-hi/strings.xml | 31 ++ app/src/main/res/values-it/strings.xml | 31 ++ app/src/main/res/values-ko/strings.xml | 31 ++ app/src/main/res/values-pl/strings.xml | 31 ++ app/src/main/res/values-pt-rBR/strings.xml | 31 ++ app/src/main/res/values-ro/strings.xml | 31 ++ app/src/main/res/values-ru/strings.xml | 31 ++ app/src/main/res/values-uk/strings.xml | 31 ++ app/src/main/res/values-zh-rCN/strings.xml | 31 ++ app/src/main/res/values-zh-rTW/strings.xml | 31 ++ app/src/main/res/values/strings.xml | 31 ++ .../display/XServerDisplayActivity.java | 27 +- 37 files changed, 2813 insertions(+), 198 deletions(-) create mode 100644 app/src/main/cpp/wn-steam-client/rust/src/chat_image.rs create mode 100644 app/src/main/cpp/wn-steam-client/rust/src/pb/cfriendmessages.rs create mode 100644 app/src/main/feature/stores/steam/achievements/SteamAchievementsScreen.kt create mode 100644 app/src/main/feature/stores/steam/data/SteamChatMessage.kt create mode 100644 app/src/main/feature/stores/steam/data/SteamFriendEntry.kt create mode 100644 app/src/main/feature/stores/steam/friends/FriendsDrawerContent.kt create mode 100644 app/src/main/feature/stores/steam/friends/SteamChatScreen.kt create mode 100644 app/src/main/feature/stores/steam/utils/SteamLaunchOptions.kt diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 6a55ffa25..9539847df 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -46,6 +46,7 @@ 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.Home @@ -137,6 +138,7 @@ internal fun LibraryGameLaunchScreen( onBack: () -> Unit, onPlay: () -> Unit, onSettings: () -> Unit, + onAchievements: (() -> Unit)? = null, onShortcut: () -> Unit, onCloudSaves: () -> Unit, onUninstall: () -> Unit, @@ -383,6 +385,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 = diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 13e32d4e9..b3dd18ddd 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -1414,6 +1414,18 @@ 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) } + LaunchedEffect(isLoggedIn) { + if (isLoggedIn) { + while (true) { + runCatching { SteamService.instance?.refreshFriends() } + kotlinx.coroutines.delay(30_000L) + } + } + } val epicApps by db.epicGameDao().getAll().collectAsState(initial = emptyList()) val gogApps by db.gogGameDao().getAll().collectAsState(initial = emptyList()) @@ -1641,6 +1653,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 +1795,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 { @@ -1883,10 +1939,20 @@ class UnifiedActivity : 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() } }, + ) + } } } } } // end ModalNavigationDrawer + } // end inner LTR + } // end right friends ModalNavigationDrawer + } // end RTL provider if (globalSettingsApp != null) { GameSettingsDialog( @@ -1908,8 +1974,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 +2054,7 @@ class UnifiedActivity : @Composable private fun DrawerSwipeHotZone( modifier: Modifier = Modifier, + isRightSide: Boolean = false, onOpenDrawer: () -> Unit, ) { val density = LocalDensity.current @@ -1986,8 +2064,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 +2075,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 +2105,7 @@ class UnifiedActivity : searchQuery: TextFieldValue, onSearchQueryChange: (TextFieldValue) -> Unit, onFilterClicked: () -> Unit, + onFriendsClicked: () -> Unit = {}, onGameSettingsClicked: () -> Unit, ) { var isSearchExpanded by remember { mutableStateOf(false) } @@ -2258,6 +2338,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 +4419,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) } @@ -5002,6 +5097,9 @@ class UnifiedActivity : ShortcutSettingsComposeDialog(this@UnifiedActivity, shortcut).show() } }, + onAchievements = if (!isCustom && !isEpic && !isGog) { + { showAchievements = true } + } else null, onShortcut = { if (hasPinnedShortcut) { currentScreen = LibraryDetailScreen.Shortcut @@ -5310,6 +5408,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 = @@ -9355,6 +9469,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 +9532,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 +9577,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 +10359,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 +10374,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..8dad9cd08 --- /dev/null +++ b/app/src/main/cpp/wn-steam-client/rust/src/pb/cfriendmessages.rs @@ -0,0 +1,227 @@ +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()?, + (5, 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); + } +} 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 b49d31e17..57f503230 100644 --- a/app/src/main/feature/library/GameSettings.kt +++ b/app/src/main/feature/library/GameSettings.kt @@ -444,6 +444,13 @@ 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%", + "PROTON_NO_ESYNC=1 %command%", "WINEDLLOVERRIDES=\"d3d11=n,b\" %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/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..843814641 --- /dev/null +++ b/app/src/main/feature/stores/steam/friends/FriendsDrawerContent.kt @@ -0,0 +1,397 @@ +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.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.Surface +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.res.stringResource +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.R +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 + +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) } + 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, + ) + } + } + 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 } + } + } + } + } +} + +@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..001f13f0e --- /dev/null +++ b/app/src/main/feature/stores/steam/friends/SteamChatScreen.kt @@ -0,0 +1,356 @@ +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.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.delay +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() + messages.addAll(SteamService.instance?.loadChatHistory(friend.steamId) ?: emptyList()) + loading = false + if (messages.isNotEmpty()) listState.scrollToItem(messages.size - 1) + while (true) { + delay(1500L) + val incoming = SteamService.instance?.drainIncomingMessages() ?: emptyMap() + val mine = incoming[friend.steamId] + if (!mine.isNullOrEmpty()) { + val known = messages.map { it.timestamp to it.ordinal }.toHashSet() + var added = false + for (m in mine) { + if ((m.timestamp to m.ordinal) in known) continue + 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) + added = true + } + } + if (added) 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 " + 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/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index d2f6cefd3..c55359d3b 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 @@ -285,6 +286,9 @@ class SteamService : Service() { ) val localPersona = _localPersona.asStateFlow() + private val _friendsList = MutableStateFlow>(emptyList()) + val friendsList = _friendsList.asStateFlow() + data class ManifestSizes( val installSize: Long = 0L, val downloadSize: Long = 0L, @@ -364,6 +368,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) { @@ -4997,10 +5041,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) -> @@ -8539,6 +8582,147 @@ 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) + } + } + + 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 = 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 = 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 + } + /** * 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/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/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 5f8f3f8c7..19caa8ba4 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1319,4 +1319,35 @@ 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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 56b45d4df..dee90a454 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1319,4 +1319,35 @@ 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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b8e3f8c73..3bb7cc903 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1319,5 +1319,36 @@ 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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fae57e852..281b07e78 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1319,5 +1319,36 @@ 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 diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index d3b283ce3..e348efd98 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1256,4 +1256,35 @@ Workshop आइटम लोड हो रहे हैं Workshop आइटम खोजें विफल + स्नूज़ + व्यस्त + व्यापार के इच्छुक + खेलने के इच्छुक + मित्र — %1$d ऑनलाइन + खेल में (%1$d) + ऑनलाइन (%1$d) + ऑफ़लाइन (%1$d) + अभी तक कोई मित्र लोड नहीं हुआ। + आप + खेल में + शामिल हों + (छवि भेजने में विफल) + अभी तक कोई संदेश नहीं। नमस्ते कहें! + छवि अपलोड हो रही है… + छवि भेजें + संदेश… + भेजें + छवि + वापस + उपलब्धियाँ + इस गेम के लिए कोई उपलब्धि नहीं मिली,\nया उन्हें लोड करने के लिए Steam में साइन इन करें। + छिपी हुई उपलब्धि + इस उपलब्धि को प्रकट करने के लिए खेलते रहें। + अनलॉक किया %1$s + अनलॉक किया + %2$s में %1$s से जुड़ रहे हैं… + %2$s से जुड़ने के लिए %1$s इंस्टॉल करें + आपके पास %1$s नहीं है + (नहीं भेजा गया) + गेम diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 548499e0c..828cd9c65 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1319,5 +1319,36 @@ 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 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 74d529a63..1388cfaf9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1320,5 +1320,36 @@ Workshop 항목 로드 중 Workshop 항목 검색 실패 + 잠시 자리 비움 + 다른 용무 중 + 교환 희망 + 게임 희망 + 친구 — %1$d명 온라인 + 게임 중 (%1$d) + 온라인 (%1$d) + 오프라인 (%1$d) + 아직 불러온 친구가 없습니다. + + 게임 중 + 참가 + (이미지 전송 실패) + 아직 메시지가 없습니다. 인사해 보세요! + 이미지 업로드 중… + 이미지 보내기 + 메시지… + 보내기 + 이미지 + 뒤로 + 도전 과제 + 이 게임의 도전 과제를 찾을 수 없습니다.\n또는 Steam에 로그인하여 불러오세요. + 숨겨진 도전 과제 + 이 도전 과제를 확인하려면 계속 플레이하세요. + %1$s 달성 + 달성 + %2$s에서 %1$s님 참가 중… + %2$s에 참가하려면 %1$s을(를) 설치하세요 + %1$s을(를) 보유하고 있지 않습니다 + (전송되지 않음) + 게임 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 67a4b707e..5303ab0d9 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1325,5 +1325,36 @@ 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ę diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index da16d13ce..670bb5ca9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1319,5 +1319,36 @@ 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 diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index f4c458983..ab6d1b5fa 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1319,5 +1319,36 @@ 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 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 014297194..6222b1191 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1225,4 +1225,35 @@ Загрузка элементов Workshop Поиск элементов Workshop Ошибка + Спящий режим + Занят + Хочет обмен + Хочет играть + Друзья — %1$d в сети + В игре (%1$d) + В сети (%1$d) + Не в сети (%1$d) + Друзья ещё не загружены. + Вы + В игре + Войти + (не удалось отправить изображение) + Сообщений пока нет. Поздоровайтесь! + Отправка изображения… + Отправить изображение + Сообщение… + Отправить + Изображение + Назад + Достижения + Достижения для этой игры не найдены,\nили войдите в Steam, чтобы загрузить их. + Скрытое достижение + Продолжайте играть, чтобы открыть это достижение. + Получено %1$s + Получено + Подключение к %1$s в %2$s… + Установите %1$s, чтобы подключиться к %2$s + У вас нет %1$s + (не отправлено) + игру diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 9f88c9d84..21dc22c82 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1325,5 +1325,36 @@ Завантаження елементів Workshop Пошук елементів Workshop Помилка + Сплячий режим + Зайнятий + Хоче обмін + Хоче грати + Друзі — %1$d у мережі + У грі (%1$d) + У мережі (%1$d) + Не в мережі (%1$d) + Друзів ще не завантажено. + Ви + У грі + Приєднатися + (не вдалося надіслати зображення) + Повідомлень ще немає. Привітайтеся! + Завантаження зображення… + Надіслати зображення + Повідомлення… + Надіслати + Зображення + Назад + Досягнення + Досягнень для цієї гри не знайдено,\nабо увійдіть у Steam, щоб завантажити їх. + Приховане досягнення + Продовжуйте грати, щоб відкрити це досягнення. + Отримано %1$s + Отримано + Приєднання до %1$s у %2$s… + Встановіть %1$s, щоб приєднатися до %2$s + У вас немає %1$s + (не надіслано) + гру diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d87aa5eeb..759ae3276 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1319,5 +1319,36 @@ 正在加载 Workshop 项目 搜索 Workshop 项目 失败 + 小憩 + 忙碌 + 想交易 + 想一起玩 + 好友 — %1$d 在线 + 游戏中 (%1$d) + 在线 (%1$d) + 离线 (%1$d) + 尚未加载好友。 + + 游戏中 + 加入 + (图片发送失败) + 还没有消息。打个招呼吧! + 正在上传图片… + 发送图片 + 消息… + 发送 + 图片 + 返回 + 成就 + 未找到此游戏的成就,\n或登录 Steam 以加载它们。 + 隐藏成就 + 继续游戏以揭示此成就。 + 已解锁 %1$s + 已解锁 + 正在加入 %1$s 的 %2$s… + 安装 %1$s 以加入 %2$s + 你未拥有 %1$s + (未发送) + 游戏 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 699060c65..beaa86ef4 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1319,5 +1319,36 @@ 正在載入 Workshop 項目 搜尋 Workshop 項目 失敗 + 小憩 + 忙碌 + 想交易 + 想一起玩 + 好友 — %1$d 在線 + 遊戲中 (%1$d) + 在線 (%1$d) + 離線 (%1$d) + 尚未載入好友。 + + 遊戲中 + 加入 + (圖片傳送失敗) + 還沒有訊息。打個招呼吧! + 正在上傳圖片… + 傳送圖片 + 訊息… + 傳送 + 圖片 + 返回 + 成就 + 找不到此遊戲的成就,\n或登入 Steam 以載入它們。 + 隱藏成就 + 繼續遊玩以揭示此成就。 + 已解鎖 %1$s + 已解鎖 + 正在加入 %1$s 的 %2$s… + 安裝 %1$s 以加入 %2$s + 你未擁有 %1$s + (未傳送) + 遊戲 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1690bca9..b6695794d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1321,4 +1321,35 @@ 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 diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 68c8a243e..ad50f48f2 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -5389,6 +5389,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<>(); @@ -6717,7 +6727,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(shortcut.getSettingExtra("execArgs", container.getExecArgs()))); steamExtraArgs = (steamExtraArgs != null && !steamExtraArgs.isEmpty()) ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); @@ -7104,7 +7116,7 @@ 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(perGameExecArgs)); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7115,6 +7127,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); @@ -7180,7 +7201,7 @@ 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(perGameExecArgs)); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); From e47104da2515f1c518deec2b45480c7dadce43a5 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Fri, 5 Jun 2026 17:57:44 -0400 Subject: [PATCH 02/21] Add Steam Friends, messaging, images, achievements, launch options, join game Steam Friends: - Right swipe-out drawer (Compose M3) mirroring the left drawer, with self status header, avatars, and In-Game/Online/Offline grouping. - Live friend presence: logon sends protocol_version, ChangeStatus with persona_set_by_user/need_persona_response, and subscribes via Chat.RequestFriendPersonaStates so Steam pushes stateful persona updates. - Wire-type-aware CMsgClientPersonaState parser (field 25 arrives as a fixed64 in stateful pushes); has_persona_state/has_game guards keep metadata-only responses from clobbering live state, game name, and app id. - In-game friends show a compact card with a text-height game-art capsule and the resolved game title beside it (title from the local app DB, then the public store API, since Steam omits game_name for Steam apps). - The redundant self-status section was removed from the left filter drawer. Steam Messages: - FriendMessages protobufs (SendMessage, GetRecentMessages, IncomingMessage) plus JNI for send, history, and a drainable incoming queue fed by the FriendMessagesClient.IncomingMessage notification. - Compose M3 chat screen with history, real-time incoming, optimistic send dedup, and avatars. - Image attachments: system photo picker -> Steam chat UGC upload (beginfileupload/PUT/commitfileupload with a minted web access token, file_type=MIME) delivered to the friend. Renders [img], [img src=...], and bare images.steamusercontent.com/ugc image URLs. Steam Achievements: - Trophy button on the game launch screen between Settings and Create Shortcut, opening a Compose M3 achievements screen with icons, names, descriptions, X/Y progress, and unlock dates. - Overlay the user's real unlock state from CMsgClientGetUserStatsResponse achievement blocks onto the schema-derived definitions. Join Game: - Join button on joinable in-game friends launches their game (when owned and installed) with the connect string appended to the Steam launch args (ColdClient ExeCommandLine and the Goldberg arg path). Launch options: - Steam-style %command% launch options: KEY=VALUE tokens before %command% become environment variables for the game process; arguments after %command% are passed to the game. Added a "Steam (%command%)" preset. Localization: - All new user-facing strings extracted to resources and translated across all 15 supported locales (da, de, es, fr, hi, it, ko, pl, pt-BR, ro, ru, uk, zh-CN, zh-TW, plus English). --- .../main/app/shell/LibraryGameLaunchScreen.kt | 10 + app/src/main/app/shell/UnifiedActivity.kt | 301 ++++++------- .../wn-steam-client/rust/src/chat_image.rs | 239 +++++++++++ .../cpp/wn-steam-client/rust/src/cm_client.rs | 157 ++++++- .../wn-steam-client/rust/src/cm_runtime.rs | 8 + .../main/cpp/wn-steam-client/rust/src/emsg.rs | 1 + .../main/cpp/wn-steam-client/rust/src/jni.rs | 190 +++++++++ .../main/cpp/wn-steam-client/rust/src/lib.rs | 1 + .../rust/src/pb/cfriendmessages.rs | 245 +++++++++++ .../rust/src/pb/cmsg_client_change_status.rs | 8 + .../rust/src/pb/cmsg_client_persona.rs | 54 ++- .../cpp/wn-steam-client/rust/src/pb/mod.rs | 1 + app/src/main/feature/library/GameSettings.kt | 6 + .../achievements/SteamAchievementsScreen.kt | 244 +++++++++++ .../stores/steam/data/SteamChatMessage.kt | 8 + .../stores/steam/data/SteamFriendEntry.kt | 35 ++ .../steam/friends/FriendsDrawerContent.kt | 397 ++++++++++++++++++ .../stores/steam/friends/SteamChatScreen.kt | 356 ++++++++++++++++ .../stores/steam/service/SteamService.kt | 190 ++++++++- .../stores/steam/utils/SteamLaunchOptions.kt | 53 +++ .../stores/steam/wnsteam/WnSteamSession.kt | 32 ++ app/src/main/res/values-da/strings.xml | 31 ++ app/src/main/res/values-de/strings.xml | 31 ++ app/src/main/res/values-es/strings.xml | 31 ++ app/src/main/res/values-fr/strings.xml | 31 ++ app/src/main/res/values-hi/strings.xml | 31 ++ app/src/main/res/values-it/strings.xml | 31 ++ app/src/main/res/values-ko/strings.xml | 31 ++ app/src/main/res/values-pl/strings.xml | 31 ++ app/src/main/res/values-pt-rBR/strings.xml | 31 ++ app/src/main/res/values-ro/strings.xml | 31 ++ app/src/main/res/values-ru/strings.xml | 31 ++ app/src/main/res/values-uk/strings.xml | 31 ++ app/src/main/res/values-zh-rCN/strings.xml | 31 ++ app/src/main/res/values-zh-rTW/strings.xml | 31 ++ app/src/main/res/values/strings.xml | 31 ++ .../display/XServerDisplayActivity.java | 27 +- 37 files changed, 2830 insertions(+), 198 deletions(-) create mode 100644 app/src/main/cpp/wn-steam-client/rust/src/chat_image.rs create mode 100644 app/src/main/cpp/wn-steam-client/rust/src/pb/cfriendmessages.rs create mode 100644 app/src/main/feature/stores/steam/achievements/SteamAchievementsScreen.kt create mode 100644 app/src/main/feature/stores/steam/data/SteamChatMessage.kt create mode 100644 app/src/main/feature/stores/steam/data/SteamFriendEntry.kt create mode 100644 app/src/main/feature/stores/steam/friends/FriendsDrawerContent.kt create mode 100644 app/src/main/feature/stores/steam/friends/SteamChatScreen.kt create mode 100644 app/src/main/feature/stores/steam/utils/SteamLaunchOptions.kt diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 6a55ffa25..9539847df 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -46,6 +46,7 @@ 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.Home @@ -137,6 +138,7 @@ internal fun LibraryGameLaunchScreen( onBack: () -> Unit, onPlay: () -> Unit, onSettings: () -> Unit, + onAchievements: (() -> Unit)? = null, onShortcut: () -> Unit, onCloudSaves: () -> Unit, onUninstall: () -> Unit, @@ -383,6 +385,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 = diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 13e32d4e9..b3dd18ddd 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -1414,6 +1414,18 @@ 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) } + LaunchedEffect(isLoggedIn) { + if (isLoggedIn) { + while (true) { + runCatching { SteamService.instance?.refreshFriends() } + kotlinx.coroutines.delay(30_000L) + } + } + } val epicApps by db.epicGameDao().getAll().collectAsState(initial = emptyList()) val gogApps by db.gogGameDao().getAll().collectAsState(initial = emptyList()) @@ -1641,6 +1653,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 +1795,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 { @@ -1883,10 +1939,20 @@ class UnifiedActivity : 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() } }, + ) + } } } } } // end ModalNavigationDrawer + } // end inner LTR + } // end right friends ModalNavigationDrawer + } // end RTL provider if (globalSettingsApp != null) { GameSettingsDialog( @@ -1908,8 +1974,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 +2054,7 @@ class UnifiedActivity : @Composable private fun DrawerSwipeHotZone( modifier: Modifier = Modifier, + isRightSide: Boolean = false, onOpenDrawer: () -> Unit, ) { val density = LocalDensity.current @@ -1986,8 +2064,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 +2075,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 +2105,7 @@ class UnifiedActivity : searchQuery: TextFieldValue, onSearchQueryChange: (TextFieldValue) -> Unit, onFilterClicked: () -> Unit, + onFriendsClicked: () -> Unit = {}, onGameSettingsClicked: () -> Unit, ) { var isSearchExpanded by remember { mutableStateOf(false) } @@ -2258,6 +2338,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 +4419,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) } @@ -5002,6 +5097,9 @@ class UnifiedActivity : ShortcutSettingsComposeDialog(this@UnifiedActivity, shortcut).show() } }, + onAchievements = if (!isCustom && !isEpic && !isGog) { + { showAchievements = true } + } else null, onShortcut = { if (hasPinnedShortcut) { currentScreen = LibraryDetailScreen.Shortcut @@ -5310,6 +5408,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 = @@ -9355,6 +9469,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 +9532,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 +9577,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 +10359,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 +10374,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 b49d31e17..5acd6de8f 100644 --- a/app/src/main/feature/library/GameSettings.kt +++ b/app/src/main/feature/library/GameSettings.kt @@ -444,6 +444,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/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..843814641 --- /dev/null +++ b/app/src/main/feature/stores/steam/friends/FriendsDrawerContent.kt @@ -0,0 +1,397 @@ +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.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.Surface +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.res.stringResource +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.R +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 + +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) } + 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, + ) + } + } + 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 } + } + } + } + } +} + +@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..001f13f0e --- /dev/null +++ b/app/src/main/feature/stores/steam/friends/SteamChatScreen.kt @@ -0,0 +1,356 @@ +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.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.delay +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() + messages.addAll(SteamService.instance?.loadChatHistory(friend.steamId) ?: emptyList()) + loading = false + if (messages.isNotEmpty()) listState.scrollToItem(messages.size - 1) + while (true) { + delay(1500L) + val incoming = SteamService.instance?.drainIncomingMessages() ?: emptyMap() + val mine = incoming[friend.steamId] + if (!mine.isNullOrEmpty()) { + val known = messages.map { it.timestamp to it.ordinal }.toHashSet() + var added = false + for (m in mine) { + if ((m.timestamp to m.ordinal) in known) continue + 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) + added = true + } + } + if (added) 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 " + 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/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index d2f6cefd3..7bb32f730 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 @@ -285,6 +286,9 @@ class SteamService : Service() { ) val localPersona = _localPersona.asStateFlow() + private val _friendsList = MutableStateFlow>(emptyList()) + val friendsList = _friendsList.asStateFlow() + data class ManifestSizes( val installSize: Long = 0L, val downloadSize: Long = 0L, @@ -364,6 +368,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) { @@ -4997,10 +5041,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) -> @@ -8539,6 +8582,147 @@ 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) + } + } + + 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 + } + /** * 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/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/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 5f8f3f8c7..19caa8ba4 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1319,4 +1319,35 @@ 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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 56b45d4df..dee90a454 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1319,4 +1319,35 @@ 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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b8e3f8c73..3bb7cc903 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1319,5 +1319,36 @@ 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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fae57e852..281b07e78 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1319,5 +1319,36 @@ 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 diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index d3b283ce3..e348efd98 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1256,4 +1256,35 @@ Workshop आइटम लोड हो रहे हैं Workshop आइटम खोजें विफल + स्नूज़ + व्यस्त + व्यापार के इच्छुक + खेलने के इच्छुक + मित्र — %1$d ऑनलाइन + खेल में (%1$d) + ऑनलाइन (%1$d) + ऑफ़लाइन (%1$d) + अभी तक कोई मित्र लोड नहीं हुआ। + आप + खेल में + शामिल हों + (छवि भेजने में विफल) + अभी तक कोई संदेश नहीं। नमस्ते कहें! + छवि अपलोड हो रही है… + छवि भेजें + संदेश… + भेजें + छवि + वापस + उपलब्धियाँ + इस गेम के लिए कोई उपलब्धि नहीं मिली,\nया उन्हें लोड करने के लिए Steam में साइन इन करें। + छिपी हुई उपलब्धि + इस उपलब्धि को प्रकट करने के लिए खेलते रहें। + अनलॉक किया %1$s + अनलॉक किया + %2$s में %1$s से जुड़ रहे हैं… + %2$s से जुड़ने के लिए %1$s इंस्टॉल करें + आपके पास %1$s नहीं है + (नहीं भेजा गया) + गेम diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 548499e0c..828cd9c65 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1319,5 +1319,36 @@ 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 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 74d529a63..1388cfaf9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1320,5 +1320,36 @@ Workshop 항목 로드 중 Workshop 항목 검색 실패 + 잠시 자리 비움 + 다른 용무 중 + 교환 희망 + 게임 희망 + 친구 — %1$d명 온라인 + 게임 중 (%1$d) + 온라인 (%1$d) + 오프라인 (%1$d) + 아직 불러온 친구가 없습니다. + + 게임 중 + 참가 + (이미지 전송 실패) + 아직 메시지가 없습니다. 인사해 보세요! + 이미지 업로드 중… + 이미지 보내기 + 메시지… + 보내기 + 이미지 + 뒤로 + 도전 과제 + 이 게임의 도전 과제를 찾을 수 없습니다.\n또는 Steam에 로그인하여 불러오세요. + 숨겨진 도전 과제 + 이 도전 과제를 확인하려면 계속 플레이하세요. + %1$s 달성 + 달성 + %2$s에서 %1$s님 참가 중… + %2$s에 참가하려면 %1$s을(를) 설치하세요 + %1$s을(를) 보유하고 있지 않습니다 + (전송되지 않음) + 게임 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 67a4b707e..5303ab0d9 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1325,5 +1325,36 @@ 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ę diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index da16d13ce..670bb5ca9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1319,5 +1319,36 @@ 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 diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index f4c458983..ab6d1b5fa 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1319,5 +1319,36 @@ 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 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 014297194..6222b1191 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1225,4 +1225,35 @@ Загрузка элементов Workshop Поиск элементов Workshop Ошибка + Спящий режим + Занят + Хочет обмен + Хочет играть + Друзья — %1$d в сети + В игре (%1$d) + В сети (%1$d) + Не в сети (%1$d) + Друзья ещё не загружены. + Вы + В игре + Войти + (не удалось отправить изображение) + Сообщений пока нет. Поздоровайтесь! + Отправка изображения… + Отправить изображение + Сообщение… + Отправить + Изображение + Назад + Достижения + Достижения для этой игры не найдены,\nили войдите в Steam, чтобы загрузить их. + Скрытое достижение + Продолжайте играть, чтобы открыть это достижение. + Получено %1$s + Получено + Подключение к %1$s в %2$s… + Установите %1$s, чтобы подключиться к %2$s + У вас нет %1$s + (не отправлено) + игру diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 9f88c9d84..21dc22c82 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1325,5 +1325,36 @@ Завантаження елементів Workshop Пошук елементів Workshop Помилка + Сплячий режим + Зайнятий + Хоче обмін + Хоче грати + Друзі — %1$d у мережі + У грі (%1$d) + У мережі (%1$d) + Не в мережі (%1$d) + Друзів ще не завантажено. + Ви + У грі + Приєднатися + (не вдалося надіслати зображення) + Повідомлень ще немає. Привітайтеся! + Завантаження зображення… + Надіслати зображення + Повідомлення… + Надіслати + Зображення + Назад + Досягнення + Досягнень для цієї гри не знайдено,\nабо увійдіть у Steam, щоб завантажити їх. + Приховане досягнення + Продовжуйте грати, щоб відкрити це досягнення. + Отримано %1$s + Отримано + Приєднання до %1$s у %2$s… + Встановіть %1$s, щоб приєднатися до %2$s + У вас немає %1$s + (не надіслано) + гру diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d87aa5eeb..759ae3276 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1319,5 +1319,36 @@ 正在加载 Workshop 项目 搜索 Workshop 项目 失败 + 小憩 + 忙碌 + 想交易 + 想一起玩 + 好友 — %1$d 在线 + 游戏中 (%1$d) + 在线 (%1$d) + 离线 (%1$d) + 尚未加载好友。 + + 游戏中 + 加入 + (图片发送失败) + 还没有消息。打个招呼吧! + 正在上传图片… + 发送图片 + 消息… + 发送 + 图片 + 返回 + 成就 + 未找到此游戏的成就,\n或登录 Steam 以加载它们。 + 隐藏成就 + 继续游戏以揭示此成就。 + 已解锁 %1$s + 已解锁 + 正在加入 %1$s 的 %2$s… + 安装 %1$s 以加入 %2$s + 你未拥有 %1$s + (未发送) + 游戏 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 699060c65..beaa86ef4 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1319,5 +1319,36 @@ 正在載入 Workshop 項目 搜尋 Workshop 項目 失敗 + 小憩 + 忙碌 + 想交易 + 想一起玩 + 好友 — %1$d 在線 + 遊戲中 (%1$d) + 在線 (%1$d) + 離線 (%1$d) + 尚未載入好友。 + + 遊戲中 + 加入 + (圖片傳送失敗) + 還沒有訊息。打個招呼吧! + 正在上傳圖片… + 傳送圖片 + 訊息… + 傳送 + 圖片 + 返回 + 成就 + 找不到此遊戲的成就,\n或登入 Steam 以載入它們。 + 隱藏成就 + 繼續遊玩以揭示此成就。 + 已解鎖 %1$s + 已解鎖 + 正在加入 %1$s 的 %2$s… + 安裝 %1$s 以加入 %2$s + 你未擁有 %1$s + (未傳送) + 遊戲 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1690bca9..b6695794d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1321,4 +1321,35 @@ 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 diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 68c8a243e..ad50f48f2 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -5389,6 +5389,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<>(); @@ -6717,7 +6727,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(shortcut.getSettingExtra("execArgs", container.getExecArgs()))); steamExtraArgs = (steamExtraArgs != null && !steamExtraArgs.isEmpty()) ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); @@ -7104,7 +7116,7 @@ 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(perGameExecArgs)); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7115,6 +7127,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); @@ -7180,7 +7201,7 @@ 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(perGameExecArgs)); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); From 238ff6977eb32d0eeb8fe346ab0aa6a1287515c4 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 8 Jun 2026 11:38:53 -0400 Subject: [PATCH 03/21] Add Steam chat notifications, chat heads, and in-game chat Settings gear in the friends drawer self-status row opens a Chat Settings dialog: Chat Notifications, Chat Heads, Auto-Hide, and Enable Chat in Game. - Global incoming-message pipeline in SteamService: a single poller drains messages and re-publishes via a SharedFlow plus per-friend unread counts, consumed by the chat screen, system notifications, and the chat-head overlay. - System notifications (high-importance channel) for incoming friend messages. - Messenger-style chat heads: a draggable system-overlay bubble that snaps to any screen edge, fades when idle (Auto-Hide), shows an unread badge, and un-hides on a new message; tapping pops a panel beside the head with the full friends list (game-art badges) and a conversation view (text + image send via a transparent picker proxy). Works over games when Enable Chat in Game is on. - In-game gating via GameSessionState; orientation-aware re-snapping. --- app/src/main/AndroidManifest.xml | 13 + app/src/main/app/shell/UnifiedActivity.kt | 14 + .../steam/chat/ChatImagePickerActivity.kt | 48 + .../stores/steam/chat/ChatOverlayService.kt | 865 ++++++++++++++++++ .../steam/friends/FriendsDrawerContent.kt | 114 +++ .../stores/steam/friends/SteamChatScreen.kt | 45 +- .../stores/steam/service/GameSessionState.kt | 24 + .../stores/steam/service/SteamService.kt | 129 +++ .../feature/stores/steam/utils/PrefManager.kt | 24 + app/src/main/res/values-da/strings.xml | 16 + app/src/main/res/values-de/strings.xml | 16 + app/src/main/res/values-es/strings.xml | 16 + app/src/main/res/values-fr/strings.xml | 16 + app/src/main/res/values-hi/strings.xml | 16 + app/src/main/res/values-it/strings.xml | 16 + app/src/main/res/values-ko/strings.xml | 16 + app/src/main/res/values-pl/strings.xml | 16 + app/src/main/res/values-pt-rBR/strings.xml | 16 + app/src/main/res/values-ro/strings.xml | 16 + app/src/main/res/values-ru/strings.xml | 16 + app/src/main/res/values-uk/strings.xml | 16 + app/src/main/res/values-zh-rCN/strings.xml | 16 + app/src/main/res/values-zh-rTW/strings.xml | 16 + app/src/main/res/values/strings.xml | 16 + .../display/XServerDisplayActivity.java | 2 + .../main/shared/android/NotificationHelper.kt | 50 + 26 files changed, 1544 insertions(+), 24 deletions(-) create mode 100644 app/src/main/feature/stores/steam/chat/ChatImagePickerActivity.kt create mode 100644 app/src/main/feature/stores/steam/chat/ChatOverlayService.kt create mode 100644 app/src/main/feature/stores/steam/service/GameSessionState.kt 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" /> + + + + ()) } var chatFriend by remember { mutableStateOf(null) } + val friendsDrawerOpen = rightDrawerState.isOpen LaunchedEffect(isLoggedIn) { if (isLoggedIn) { while (true) { @@ -1426,6 +1427,19 @@ class UnifiedActivity : } } } + 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()) 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..a7eb03c7b --- /dev/null +++ b/app/src/main/feature/stores/steam/chat/ChatOverlayService.kt @@ -0,0 +1,865 @@ +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 } + private val bubbleSizePx by lazy { (64 * 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( + 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_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/friends/FriendsDrawerContent.kt b/app/src/main/feature/stores/steam/friends/FriendsDrawerContent.kt index 843814641..2ed817101 100644 --- a/app/src/main/feature/stores/steam/friends/FriendsDrawerContent.kt +++ b/app/src/main/feature/stores/steam/friends/FriendsDrawerContent.kt @@ -24,10 +24,14 @@ 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 @@ -40,15 +44,19 @@ 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) @@ -162,6 +170,7 @@ private fun SectionHeader(text: String) { @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, @@ -191,6 +200,17 @@ private fun SelfCard(self: SteamFriend, onSetState: (EPersonaState) -> Unit) { ) } } + 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, @@ -211,6 +231,100 @@ private fun SelfCard(self: SteamFriend, onSetState: (EPersonaState) -> Unit) { } } } + 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 diff --git a/app/src/main/feature/stores/steam/friends/SteamChatScreen.kt b/app/src/main/feature/stores/steam/friends/SteamChatScreen.kt index 001f13f0e..d7a82811c 100644 --- a/app/src/main/feature/stores/steam/friends/SteamChatScreen.kt +++ b/app/src/main/feature/stores/steam/friends/SteamChatScreen.kt @@ -35,6 +35,7 @@ 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 @@ -61,7 +62,7 @@ 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.delay +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -134,36 +135,32 @@ fun SteamChatScreen( 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) - while (true) { - delay(1500L) - val incoming = SteamService.instance?.drainIncomingMessages() ?: emptyMap() - val mine = incoming[friend.steamId] - if (!mine.isNullOrEmpty()) { - val known = messages.map { it.timestamp to it.ordinal }.toHashSet() - var added = false - for (m in mine) { - if ((m.timestamp to m.ordinal) in known) continue - 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) - added = true - } + 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)) } - if (added) listState.animateScrollToItem(messages.size - 1) + } 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() 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 7bb32f730..da10e9098 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -123,7 +123,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 @@ -289,6 +291,22 @@ class SteamService : Service() { 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, @@ -7202,6 +7220,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)) @@ -7983,6 +8002,7 @@ class SteamService : Service() { refreshTokenWatchdogJob?.cancel() picsChangesCheckerJob?.cancel() picsGetProductInfoJob?.cancel() + messagePollerJob?.cancel() wnSession?.let { s -> runCatching { s.disconnect() } } return true } @@ -8051,6 +8071,7 @@ class SteamService : Service() { refreshTokenWatchdogJob?.cancel() picsChangesCheckerJob?.cancel() picsGetProductInfoJob?.cancel() + messagePollerJob?.cancel() wnSession?.let { s -> runCatching { s.logOffAndDisconnect(500) } } } @@ -8355,6 +8376,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() @@ -8637,6 +8660,38 @@ class SteamService : Service() { } } + 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. @@ -8723,6 +8778,80 @@ class SteamService : Service() { 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/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/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 58d82fc52..ac069c0a4 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1350,4 +1350,20 @@ Installeret sti: 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 e18f7d90b..91a41952f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1350,4 +1350,20 @@ Installierter Pfad: 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 bdc12e968..c16d2bf0e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1350,5 +1350,21 @@ Ruta instalada: 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 228db1aa9..a030bd2dd 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1350,5 +1350,21 @@ Chemin installé : 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 bd2927469..4dea7f2ce 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1287,4 +1287,20 @@ आपके पास %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 b600c08f0..3b03bf696 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1350,5 +1350,21 @@ Percorso installato: 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 61cc21388..c950b49e2 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1351,5 +1351,21 @@ %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 baccbddda..9709b43f4 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1356,5 +1356,21 @@ Zainstalowana ścieżka: 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 e1bdc26cd..a420cd55c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1350,5 +1350,21 @@ Caminho instalado: 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 b5ff4f830..8f1c6364c 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1350,5 +1350,21 @@ Cale instalata: 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 f5bdb172e..a749be3e2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1256,4 +1256,20 @@ У вас нет %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 0524b2fa8..bc8dc4cdf 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1356,5 +1356,21 @@ У вас немає %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 97d834761..9e2786c29 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1350,5 +1350,21 @@ 你未拥有 %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 f92df7441..537c30699 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1350,5 +1350,21 @@ 你未擁有 %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 163cb9039..874e7afe4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1357,4 +1357,20 @@ Installed path: 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 0c8b37ecf..86c33072e 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(); 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) { From 56ed350f4783731ae260739d341d5737889b06c4 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 8 Jun 2026 20:09:09 -0400 Subject: [PATCH 04/21] Fix tap dead zone over Add Custom Game FAB and pin chat-head overlay to a fixed size --- app/src/main/app/shell/UnifiedActivity.kt | 29 ++++++++++--------- .../stores/steam/chat/ChatOverlayService.kt | 8 +++-- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 4b944eb17..b1729f29e 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -1920,6 +1920,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 = @@ -1946,20 +1961,6 @@ class UnifiedActivity : ) } } - - 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() } }, - ) - } } } } diff --git a/app/src/main/feature/stores/steam/chat/ChatOverlayService.kt b/app/src/main/feature/stores/steam/chat/ChatOverlayService.kt index a7eb03c7b..0652f058a 100644 --- a/app/src/main/feature/stores/steam/chat/ChatOverlayService.kt +++ b/app/src/main/feature/stores/steam/chat/ChatOverlayService.kt @@ -170,7 +170,8 @@ class ChatOverlayService : Service() { private val touchHandler = Handler(Looper.getMainLooper()) private val density by lazy { resources.displayMetrics.density } - private val bubbleSizePx by lazy { (64 * density).toInt() } + // 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 @@ -790,8 +791,9 @@ class ChatOverlayService : Service() { private fun buildBubbleParams(): WindowManager.LayoutParams = WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, + // 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, From 318bb5b6f50804c8be0c0a9512da462c71daf0fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:24:56 +0000 Subject: [PATCH 05/21] feat(steam): Launch Options selector in the STEAM dropdown Adds a "Launch Options" item to the STEAM menu on the game detail screen that lists the game's appinfo config.launch entries (e.g. ARK's "Launch ARK (No BattlEye)") with a checkmark on the current choice. Selecting one persists as the game's default: - exe -> shortcut extra launch_exe_path + container.executablePath (the priority resolveRelativeGameExe already honors; non-default exes ride the existing WN_STEAM_DIRECT_EXE override) - the option's own arguments -> new shortcut extra launch_exe_args, kept separate from the user's custom execArgs LaunchInfo gains an `arguments` field (defaulted, so cached appinfo JSON stays decodable) parsed from appinfo config/launch/arguments. A shared SteamUtils.combineSteamLaunchArgs joins option args with custom args across all three launch channels (direct command line, ColdClientLoader.ini ExeCommandLine, localconfig.vdf LaunchOptions / GetLaunchCommandLine). It honors Steam's %command% placeholder in custom args. The steam-env warm stamp now includes the effective LaunchOptions line so a changed selection re-runs the localconfig edit, and warm launches re-push the per-process launch command line. The submenu reuses the existing StoreSourceActionPopup surface and StoreSourceMenuItem styling; hidden unless the game is installed and has 2+ distinct options. https://claude.ai/code/session_01EUsZxzAWCjSh8LYRS9W3vT --- .../main/app/shell/StoreGameDetailScreen.kt | 153 ++++++++++++++---- app/src/main/app/shell/UnifiedActivity.kt | 61 +++++++ .../feature/stores/steam/data/LaunchInfo.kt | 2 + .../stores/steam/service/SteamService.kt | 56 +++++++ .../stores/steam/utils/KeyValueUtils.kt | 1 + .../feature/stores/steam/utils/SteamUtils.kt | 26 ++- app/src/main/res/values/strings.xml | 2 + .../display/XServerDisplayActivity.java | 29 +++- 8 files changed, 295 insertions(+), 35 deletions(-) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 64b4fbd8e..45d7b3fcc 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.FactCheck import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Delete @@ -57,6 +58,7 @@ 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.RocketLaunch import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.SystemUpdate @@ -113,6 +115,13 @@ internal data class StoreDlcItem( val isInstalled: Boolean = false, ) +internal data class StoreLaunchOptionItem( + // Relative path, '/'-separated (appinfo config.launch entry). + val executable: String, + val arguments: String, + val label: String, +) + private val StoreBlack = Color.Black private val StoreCard = Color(0xFF12121B) private val StoreAccent = Color(0xFF1A9FFF) @@ -149,6 +158,9 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, + launchOptions: List = emptyList(), + selectedLaunchOption: StoreLaunchOptionItem? = null, + onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -186,7 +198,9 @@ internal fun StoreGameDetailScreen( val showUpdateCta = updateCheckAvailable && isUpdateAvailable val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled - val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable + val launchOptionsAvailable = isInstalled && launchOptions.size >= 2 + val sourceMenuEnabled = + updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable val showDlcCard = dlcs.isNotEmpty() val showActionColumn = showDownloadCta || showUpdateCta || @@ -293,6 +307,9 @@ internal fun StoreGameDetailScreen( showCheckForUpdate = updateCheckAvailable, showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, + showLaunchOptions = launchOptionsAvailable, + launchOptions = launchOptions, + selectedLaunchOption = selectedLaunchOption, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -303,6 +320,7 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onSelectLaunchOption = onSelectLaunchOption, ) } @@ -780,14 +798,19 @@ private fun StoreSourceTag( showCheckForUpdate: Boolean = false, showVerifyFiles: Boolean = false, showWorkshop: Boolean = false, + showLaunchOptions: Boolean = false, + launchOptions: List = emptyList(), + selectedLaunchOption: StoreLaunchOptionItem? = null, isCheckingForUpdate: Boolean = false, areSteamActionsEnabled: Boolean = true, isUpdateCheckEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } + var launchOptionsPage by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } Box { Surface( @@ -797,7 +820,16 @@ private fun StoreSourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), + .then( + if (menuEnabled) { + Modifier.clickable { + launchOptionsPage = false + menuOpen = true + } + } else { + Modifier + }, + ), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -832,34 +864,63 @@ private fun StoreSourceTag( val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } StoreSourceActionPopup( expanded = menuOpen, - onDismissRequest = { menuOpen = false }, + onDismissRequest = { + menuOpen = false + launchOptionsPage = false + }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (showVerifyFiles) { + if (launchOptionsPage) { StoreSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.FactCheck, - label = stringResource(R.string.store_game_verify_files), - enabled = areSteamActionsEnabled && !isCheckingForUpdate, - ) { menuOpen = false; onVerifyFiles() } - } - if (showCheckForUpdate) { - StoreSourceMenuItem( - icon = Icons.Outlined.Refresh, - label = - if (isCheckingForUpdate) { - stringResource(R.string.store_game_checking_for_update) - } else { - stringResource(R.string.store_game_check_for_update) - }, - enabled = areSteamActionsEnabled && isUpdateCheckEnabled, - ) { menuOpen = false; onCheckForUpdate() } - } - if (showWorkshop) { - StoreSourceMenuItem( - icon = Icons.Outlined.Construction, - label = stringResource(R.string.store_game_workshop), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onWorkshop() } + icon = Icons.AutoMirrored.Outlined.ArrowBack, + label = stringResource(R.string.store_game_launch_options), + ) { launchOptionsPage = false } + StoreDlcDivider() + launchOptions.forEach { option -> + StoreLaunchOptionRow( + label = option.label, + selected = option == selectedLaunchOption, + enabled = areSteamActionsEnabled, + ) { + menuOpen = false + launchOptionsPage = false + onSelectLaunchOption(option) + } + } + } else { + if (showVerifyFiles) { + StoreSourceMenuItem( + icon = Icons.AutoMirrored.Outlined.FactCheck, + label = stringResource(R.string.store_game_verify_files), + enabled = areSteamActionsEnabled && !isCheckingForUpdate, + ) { menuOpen = false; onVerifyFiles() } + } + if (showCheckForUpdate) { + StoreSourceMenuItem( + icon = Icons.Outlined.Refresh, + label = + if (isCheckingForUpdate) { + stringResource(R.string.store_game_checking_for_update) + } else { + stringResource(R.string.store_game_check_for_update) + }, + enabled = areSteamActionsEnabled && isUpdateCheckEnabled, + ) { menuOpen = false; onCheckForUpdate() } + } + if (showWorkshop) { + StoreSourceMenuItem( + icon = Icons.Outlined.Construction, + label = stringResource(R.string.store_game_workshop), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onWorkshop() } + } + if (showLaunchOptions) { + StoreSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { launchOptionsPage = true } + } } } } @@ -948,6 +1009,44 @@ private fun StoreSourceMenuItem( } } +@Composable +private fun StoreLaunchOptionRow( + label: String, + selected: Boolean, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f) + Row( + modifier = + Modifier + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = StoreAccent, + modifier = Modifier.size(16.dp), + ) + } + } + Text( + label, + color = if (selected) StoreAccent else contentColor, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 280.dp), + ) + } +} + @Composable private fun StoreStatChip( icon: ImageVector, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 13e32d4e9..2f4493de3 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -8486,6 +8486,8 @@ 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 launchOptions by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedLaunchOption 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( @@ -8551,6 +8553,41 @@ class UnifiedActivity : } } + LaunchedEffect(app.id, installed) { + if (installed != true) { + launchOptions = emptyList() + selectedLaunchOption = null + return@LaunchedEffect + } + val (options, selected) = + withContext(Dispatchers.IO) { + val appDir = java.io.File(SteamService.getAppDirPath(app.id)) + val allOptions = + SteamService + .getWindowsLaunchInfos(app.id) + .map { info -> + StoreLaunchOptionItem( + executable = info.executable, + arguments = info.arguments, + label = info.description.ifBlank { info.executable.substringAfterLast('/') }, + ) + }.distinctBy { it.executable.lowercase() to it.arguments } + // 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(context, app.id) + 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 + } + launchOptions = options + selectedLaunchOption = selected + } + val totalDownloadSize = selectedManifestSizes.downloadSize val totalInstallSize = selectedManifestSizes.installSize val defaultPathSet = @@ -8671,6 +8708,30 @@ class UnifiedActivity : showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, + launchOptions = launchOptions, + selectedLaunchOption = selectedLaunchOption, + onSelectLaunchOption = { option -> + scope.launch(Dispatchers.IO) { + val saved = + SteamService.setSelectedLaunchOption( + applicationContext, + app.id, + option.executable, + option.arguments, + ) + withContext(Dispatchers.Main) { + if (saved) { + selectedLaunchOption = option + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.store_game_launch_option_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, 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/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index d2f6cefd3..c054ef01b 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -2306,6 +2306,62 @@ 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() + } + + /** + * 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. Call on an IO dispatcher. + */ + fun getSelectedLaunchOption( + context: Context, + appId: Int, + ): Pair { + val shortcut = findSteamShortcut(context, appId) + val exe = + shortcut?.getExtra("launch_exe_path").orEmpty().ifBlank { + shortcut?.container?.executablePath.orEmpty().ifBlank { getInstalledExe(appId) } + } + return exe.replace('\\', '/') to shortcut?.getExtra("launch_exe_args").orEmpty() + } + suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { val appDirPath = getAppDirPath(appId) 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/SteamUtils.kt b/app/src/main/feature/stores/steam/utils/SteamUtils.kt index 0b022ffa0..acde7feec 100644 --- a/app/src/main/feature/stores/steam/utils/SteamUtils.kt +++ b/app/src/main/feature/stores/steam/utils/SteamUtils.kt @@ -1399,15 +1399,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/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef118d423..2b80cfa53 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,8 @@ Update check failed Update Workshop + Launch Options + Couldn\'t save launch option Verify Files Verifying %1$s — check the Downloads tab Steam options diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index b3c5b5385..14e9dd510 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -6888,8 +6888,10 @@ 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()); - steamExtraArgs = (steamExtraArgs != null && !steamExtraArgs.isEmpty()) ? " " + steamExtraArgs : ""; + String steamExtraArgs = SteamUtils.combineSteamLaunchArgs( + shortcut.getExtra("launch_exe_args"), + shortcut.getSettingExtra("execArgs", container.getExecArgs())); + steamExtraArgs = !steamExtraArgs.isEmpty() ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); boolean launchBionicSteam = isBionicSteamEnabledForShortcut(); @@ -7275,7 +7277,8 @@ 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 = SteamUtils.combineSteamLaunchArgs( + shortcut != null ? shortcut.getExtra("launch_exe_args") : "", perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7351,7 +7354,8 @@ 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 = SteamUtils.combineSteamLaunchArgs( + shortcut != null ? shortcut.getExtra("launch_exe_args") : "", perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -9064,10 +9068,16 @@ 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. + String effectiveLaunchOptions = + SteamUtils.combineSteamLaunchArgs(selectedLaunchArgs, container.getExecArgs()); + String expectedStamp = "v2|" + appId + "|" + steamUserDataId + + "|" + effectiveLaunchOptions.hashCode(); String existingStamp = steamEnvStamp.exists() ? FileUtils.readString(steamEnvStamp).trim() : ""; boolean steamEnvWarm = expectedStamp.equals(existingStamp); @@ -9082,7 +9092,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 { @@ -9092,6 +9103,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"); From bbcfcbb2f1ad92744c1b8b9115aee16f34a532b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 03:08:15 +0000 Subject: [PATCH 06/21] fix(steam): show Launch Options on the Library game screen too The v1 selector only existed on the store detail screen (StoreGameDetailScreen); tapping an installed game in the library grid opens LibraryGameDetailDialog -> LibraryGameLaunchScreen, which has its own duplicated STEAM dropdown - so the feature was invisible from the library. Mirror the submenu there (same two-page popup, using this screen's Launch* styling) and wire it in LibraryGameDetailDialog for Steam games only. Also fix two latent issues that could hide or weaken the selector: - Cached appinfo rows predate LaunchInfo.arguments, so options that differ only by args deduped down to one entry and fell under the 2-option visibility gate. Dedup now includes the option label, and both dialogs do a two-phase load: show cached options immediately, then SteamService.refreshAppInfoFromPics (new public wrapper around fetchLatestSteamAppInfo + persistLatestSteamAppInfo) re-fetches the app's PICS appinfo and reloads, healing upgraded installs on first open. - Extracted the option-list builder and selection persistence into shared UnifiedActivity helpers used by both screens, replacing GameManagerDialog's inline copies. https://claude.ai/code/session_01EUsZxzAWCjSh8LYRS9W3vT --- .../main/app/shell/LibraryGameLaunchScreen.kt | 137 ++++++++++++++--- app/src/main/app/shell/UnifiedActivity.kt | 145 ++++++++++++------ .../stores/steam/service/SteamService.kt | 17 ++ 3 files changed, 233 insertions(+), 66 deletions(-) diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 6a55ffa25..ea207cf72 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -43,11 +43,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.FactCheck import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check 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.History import androidx.compose.material.icons.outlined.Refresh +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 @@ -57,6 +59,7 @@ import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -132,6 +135,9 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, + launchOptions: List = emptyList(), + selectedLaunchOption: StoreLaunchOptionItem? = null, + onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -268,10 +274,14 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles = showVerifyFiles, showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, + showLaunchOptions = launchOptions.size >= 2, + launchOptions = launchOptions, + selectedLaunchOption = selectedLaunchOption, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onSelectLaunchOption = onSelectLaunchOption, ) } @@ -795,12 +805,17 @@ private fun SourceTag( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, + showLaunchOptions: Boolean = false, + launchOptions: List = emptyList(), + selectedLaunchOption: StoreLaunchOptionItem? = null, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } + var launchOptionsPage by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } Box { Surface( @@ -810,7 +825,16 @@ private fun SourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), + .then( + if (menuEnabled) { + Modifier.clickable { + launchOptionsPage = false + menuOpen = true + } + } else { + Modifier + }, + ), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -845,29 +869,62 @@ private fun SourceTag( val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } LaunchSourceActionPopup( expanded = menuOpen, - onDismissRequest = { menuOpen = false }, + onDismissRequest = { + menuOpen = false + launchOptionsPage = false + }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (showVerifyFiles) { - LaunchSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.FactCheck, - label = stringResource(R.string.store_game_verify_files), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onVerifyFiles() } - } - if (showCheckForUpdate) { - LaunchSourceMenuItem( - icon = Icons.Outlined.Refresh, - label = stringResource(R.string.store_game_check_for_update), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onCheckForUpdate() } - } - if (showWorkshop) { + if (launchOptionsPage) { LaunchSourceMenuItem( - icon = Icons.Outlined.Construction, - label = stringResource(R.string.store_game_workshop), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onWorkshop() } + icon = Icons.AutoMirrored.Outlined.ArrowBack, + label = stringResource(R.string.store_game_launch_options), + ) { launchOptionsPage = false } + HorizontalDivider( + color = Color.White.copy(alpha = 0.16f), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 12.dp), + ) + launchOptions.forEach { option -> + LaunchOptionRow( + label = option.label, + selected = option == selectedLaunchOption, + enabled = areSteamActionsEnabled, + ) { + menuOpen = false + launchOptionsPage = false + onSelectLaunchOption(option) + } + } + } else { + if (showVerifyFiles) { + LaunchSourceMenuItem( + icon = Icons.AutoMirrored.Outlined.FactCheck, + label = stringResource(R.string.store_game_verify_files), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onVerifyFiles() } + } + if (showCheckForUpdate) { + LaunchSourceMenuItem( + icon = Icons.Outlined.Refresh, + label = stringResource(R.string.store_game_check_for_update), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onCheckForUpdate() } + } + if (showWorkshop) { + LaunchSourceMenuItem( + icon = Icons.Outlined.Construction, + label = stringResource(R.string.store_game_workshop), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onWorkshop() } + } + if (showLaunchOptions) { + LaunchSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { launchOptionsPage = true } + } } } } @@ -956,6 +1013,44 @@ private fun LaunchSourceMenuItem( } } +@Composable +private fun LaunchOptionRow( + label: String, + selected: Boolean, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f) + Row( + modifier = + Modifier + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = LaunchAccent, + modifier = Modifier.size(16.dp), + ) + } + } + Text( + label, + color = if (selected) LaunchAccent else contentColor, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 280.dp), + ) + } +} + @Composable private fun GameStatChip( icon: ImageVector, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 2f4493de3..7b6e1e0e4 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 @@ -4333,6 +4334,28 @@ 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 launchOptions by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedLaunchOption 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 + return@LaunchedEffect + } + val (options, selected) = loadSteamLaunchOptions(app.id) + launchOptions = options + selectedLaunchOption = selected + // Heal cached appinfo rows that predate LaunchInfo.arguments. + if (SteamService.refreshAppInfoFromPics(app.id)) { + val (freshOptions, freshSelected) = loadSteamLaunchOptions(app.id) + launchOptions = freshOptions + selectedLaunchOption = freshSelected + } + } val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), @@ -5053,6 +5076,13 @@ class UnifiedActivity : (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, + launchOptions = launchOptions, + selectedLaunchOption = selectedLaunchOption, + onSelectLaunchOption = { option -> + persistSteamLaunchOptionSelection(app.id, option, scope) { + selectedLaunchOption = it + } + }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -8467,6 +8497,68 @@ class UnifiedActivity : } } + /** + * 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 + } + + 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, + ) + } + } + } + } + // Game Manager Dialog @Composable fun GameManagerDialog( @@ -8559,33 +8651,15 @@ class UnifiedActivity : selectedLaunchOption = null return@LaunchedEffect } - val (options, selected) = - withContext(Dispatchers.IO) { - val appDir = java.io.File(SteamService.getAppDirPath(app.id)) - val allOptions = - SteamService - .getWindowsLaunchInfos(app.id) - .map { info -> - StoreLaunchOptionItem( - executable = info.executable, - arguments = info.arguments, - label = info.description.ifBlank { info.executable.substringAfterLast('/') }, - ) - }.distinctBy { it.executable.lowercase() to it.arguments } - // 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(context, app.id) - 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 - } + val (options, selected) = loadSteamLaunchOptions(app.id) launchOptions = options selectedLaunchOption = selected + // Heal cached appinfo rows that predate LaunchInfo.arguments. + if (SteamService.refreshAppInfoFromPics(app.id)) { + val (freshOptions, freshSelected) = loadSteamLaunchOptions(app.id) + launchOptions = freshOptions + selectedLaunchOption = freshSelected + } } val totalDownloadSize = selectedManifestSizes.downloadSize @@ -8711,26 +8785,7 @@ class UnifiedActivity : launchOptions = launchOptions, selectedLaunchOption = selectedLaunchOption, onSelectLaunchOption = { option -> - scope.launch(Dispatchers.IO) { - val saved = - SteamService.setSelectedLaunchOption( - applicationContext, - app.id, - option.executable, - option.arguments, - ) - withContext(Dispatchers.Main) { - if (saved) { - selectedLaunchOption = option - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.store_game_launch_option_failed), - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } + persistSteamLaunchOptionSelection(app.id, option, scope) { selectedLaunchOption = it } }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index c054ef01b..7f2501364 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -7482,6 +7482,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, From 99039c44a51035b2a40c4b075e47a5db740eecc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 03:17:22 +0000 Subject: [PATCH 07/21] ui(steam): Workshop-style window for the Launch Options picker The in-popup submenu rendered as an awkward full-width overlay. Replace it with a centered modal window that follows the Workshop window's design language: dimmed scrim, 560dp-max rounded Surface, header with an accent icon badge + LAUNCH OPTIONS overline + game title + count chip + close button, and a divided row list. Rows show the option label with its executable/arguments as the secondary line; tapping persists the selection and moves the check mark. - New LaunchOptionsScreen.kt mirroring WorkshopScreen's palette and layout (all vector icons; no new drawable assets needed). - Both STEAM dropdowns return to a flat menu whose "Launch Options" item opens the window (showLaunchOptions/onLaunchOptions params replace the list plumbing through the SourceTags). - LaunchOptionsDialog host added next to WorkshopDialog in both GameManagerDialog and LibraryGameDetailDialog. https://claude.ai/code/session_01EUsZxzAWCjSh8LYRS9W3vT --- app/src/main/app/shell/LaunchOptionsScreen.kt | 243 ++++++++++++++++++ .../main/app/shell/LibraryGameLaunchScreen.kt | 145 +++-------- .../main/app/shell/StoreGameDetailScreen.kt | 150 +++-------- app/src/main/app/shell/UnifiedActivity.kt | 71 ++++- 4 files changed, 371 insertions(+), 238 deletions(-) create mode 100644 app/src/main/app/shell/LaunchOptionsScreen.kt diff --git a/app/src/main/app/shell/LaunchOptionsScreen.kt b/app/src/main/app/shell/LaunchOptionsScreen.kt new file mode 100644 index 000000000..8e89cdbb2 --- /dev/null +++ b/app/src/main/app/shell/LaunchOptionsScreen.kt @@ -0,0 +1,243 @@ +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 + +// 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 ea207cf72..c035ff925 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.FactCheck import androidx.compose.material.icons.outlined.ArrowDropDown -import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Delete @@ -59,7 +58,6 @@ import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -135,9 +133,8 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, - launchOptions: List = emptyList(), - selectedLaunchOption: StoreLaunchOptionItem? = null, - onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, + showLaunchOptions: Boolean = false, + onLaunchOptions: () -> Unit = {}, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -274,14 +271,12 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles = showVerifyFiles, showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, - showLaunchOptions = launchOptions.size >= 2, - launchOptions = launchOptions, - selectedLaunchOption = selectedLaunchOption, + showLaunchOptions = showLaunchOptions, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, - onSelectLaunchOption = onSelectLaunchOption, + onLaunchOptions = onLaunchOptions, ) } @@ -806,16 +801,13 @@ private fun SourceTag( showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, showLaunchOptions: Boolean = false, - launchOptions: List = emptyList(), - selectedLaunchOption: StoreLaunchOptionItem? = null, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, + onLaunchOptions: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } - var launchOptionsPage by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } Box { Surface( @@ -825,16 +817,7 @@ private fun SourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then( - if (menuEnabled) { - Modifier.clickable { - launchOptionsPage = false - menuOpen = true - } - } else { - Modifier - }, - ), + .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -869,62 +852,36 @@ private fun SourceTag( val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } LaunchSourceActionPopup( expanded = menuOpen, - onDismissRequest = { - menuOpen = false - launchOptionsPage = false - }, + onDismissRequest = { menuOpen = false }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (launchOptionsPage) { + if (showVerifyFiles) { LaunchSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.ArrowBack, + icon = Icons.AutoMirrored.Outlined.FactCheck, + label = stringResource(R.string.store_game_verify_files), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onVerifyFiles() } + } + if (showCheckForUpdate) { + LaunchSourceMenuItem( + icon = Icons.Outlined.Refresh, + label = stringResource(R.string.store_game_check_for_update), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onCheckForUpdate() } + } + if (showWorkshop) { + LaunchSourceMenuItem( + icon = Icons.Outlined.Construction, + label = stringResource(R.string.store_game_workshop), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onWorkshop() } + } + if (showLaunchOptions) { + LaunchSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, label = stringResource(R.string.store_game_launch_options), - ) { launchOptionsPage = false } - HorizontalDivider( - color = Color.White.copy(alpha = 0.16f), - thickness = 1.dp, - modifier = Modifier.padding(horizontal = 12.dp), - ) - launchOptions.forEach { option -> - LaunchOptionRow( - label = option.label, - selected = option == selectedLaunchOption, - enabled = areSteamActionsEnabled, - ) { - menuOpen = false - launchOptionsPage = false - onSelectLaunchOption(option) - } - } - } else { - if (showVerifyFiles) { - LaunchSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.FactCheck, - label = stringResource(R.string.store_game_verify_files), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onVerifyFiles() } - } - if (showCheckForUpdate) { - LaunchSourceMenuItem( - icon = Icons.Outlined.Refresh, - label = stringResource(R.string.store_game_check_for_update), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onCheckForUpdate() } - } - if (showWorkshop) { - LaunchSourceMenuItem( - icon = Icons.Outlined.Construction, - label = stringResource(R.string.store_game_workshop), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onWorkshop() } - } - if (showLaunchOptions) { - LaunchSourceMenuItem( - icon = Icons.Outlined.RocketLaunch, - label = stringResource(R.string.store_game_launch_options), - enabled = areSteamActionsEnabled, - ) { launchOptionsPage = true } - } + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } } } } @@ -1013,44 +970,6 @@ private fun LaunchSourceMenuItem( } } -@Composable -private fun LaunchOptionRow( - label: String, - selected: Boolean, - enabled: Boolean = true, - onClick: () -> Unit, -) { - val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f) - Row( - modifier = - Modifier - .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) - .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { - if (selected) { - Icon( - Icons.Outlined.Check, - contentDescription = null, - tint = LaunchAccent, - modifier = Modifier.size(16.dp), - ) - } - } - Text( - label, - color = if (selected) LaunchAccent else contentColor, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 280.dp), - ) - } -} - @Composable private fun GameStatChip( icon: ImageVector, diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 45d7b3fcc..3451bebab 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.FactCheck import androidx.compose.material.icons.outlined.ArrowDropDown -import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Delete @@ -158,9 +157,8 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, - launchOptions: List = emptyList(), - selectedLaunchOption: StoreLaunchOptionItem? = null, - onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, + showLaunchOptions: Boolean = false, + onLaunchOptions: () -> Unit = {}, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -198,7 +196,7 @@ internal fun StoreGameDetailScreen( val showUpdateCta = updateCheckAvailable && isUpdateAvailable val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled - val launchOptionsAvailable = isInstalled && launchOptions.size >= 2 + val launchOptionsAvailable = showLaunchOptions && isInstalled val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable val showDlcCard = dlcs.isNotEmpty() @@ -308,8 +306,6 @@ internal fun StoreGameDetailScreen( showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, showLaunchOptions = launchOptionsAvailable, - launchOptions = launchOptions, - selectedLaunchOption = selectedLaunchOption, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -320,7 +316,7 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, - onSelectLaunchOption = onSelectLaunchOption, + onLaunchOptions = onLaunchOptions, ) } @@ -799,18 +795,15 @@ private fun StoreSourceTag( showVerifyFiles: Boolean = false, showWorkshop: Boolean = false, showLaunchOptions: Boolean = false, - launchOptions: List = emptyList(), - selectedLaunchOption: StoreLaunchOptionItem? = null, isCheckingForUpdate: Boolean = false, areSteamActionsEnabled: Boolean = true, isUpdateCheckEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, + onLaunchOptions: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } - var launchOptionsPage by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } Box { Surface( @@ -820,16 +813,7 @@ private fun StoreSourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then( - if (menuEnabled) { - Modifier.clickable { - launchOptionsPage = false - menuOpen = true - } - } else { - Modifier - }, - ), + .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -864,63 +848,41 @@ private fun StoreSourceTag( val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } StoreSourceActionPopup( expanded = menuOpen, - onDismissRequest = { - menuOpen = false - launchOptionsPage = false - }, + onDismissRequest = { menuOpen = false }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (launchOptionsPage) { + if (showVerifyFiles) { + StoreSourceMenuItem( + icon = Icons.AutoMirrored.Outlined.FactCheck, + label = stringResource(R.string.store_game_verify_files), + enabled = areSteamActionsEnabled && !isCheckingForUpdate, + ) { menuOpen = false; onVerifyFiles() } + } + if (showCheckForUpdate) { + StoreSourceMenuItem( + icon = Icons.Outlined.Refresh, + label = + if (isCheckingForUpdate) { + stringResource(R.string.store_game_checking_for_update) + } else { + stringResource(R.string.store_game_check_for_update) + }, + enabled = areSteamActionsEnabled && isUpdateCheckEnabled, + ) { menuOpen = false; onCheckForUpdate() } + } + if (showWorkshop) { StoreSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.ArrowBack, + icon = Icons.Outlined.Construction, + label = stringResource(R.string.store_game_workshop), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onWorkshop() } + } + if (showLaunchOptions) { + StoreSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, label = stringResource(R.string.store_game_launch_options), - ) { launchOptionsPage = false } - StoreDlcDivider() - launchOptions.forEach { option -> - StoreLaunchOptionRow( - label = option.label, - selected = option == selectedLaunchOption, - enabled = areSteamActionsEnabled, - ) { - menuOpen = false - launchOptionsPage = false - onSelectLaunchOption(option) - } - } - } else { - if (showVerifyFiles) { - StoreSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.FactCheck, - label = stringResource(R.string.store_game_verify_files), - enabled = areSteamActionsEnabled && !isCheckingForUpdate, - ) { menuOpen = false; onVerifyFiles() } - } - if (showCheckForUpdate) { - StoreSourceMenuItem( - icon = Icons.Outlined.Refresh, - label = - if (isCheckingForUpdate) { - stringResource(R.string.store_game_checking_for_update) - } else { - stringResource(R.string.store_game_check_for_update) - }, - enabled = areSteamActionsEnabled && isUpdateCheckEnabled, - ) { menuOpen = false; onCheckForUpdate() } - } - if (showWorkshop) { - StoreSourceMenuItem( - icon = Icons.Outlined.Construction, - label = stringResource(R.string.store_game_workshop), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onWorkshop() } - } - if (showLaunchOptions) { - StoreSourceMenuItem( - icon = Icons.Outlined.RocketLaunch, - label = stringResource(R.string.store_game_launch_options), - enabled = areSteamActionsEnabled, - ) { launchOptionsPage = true } - } + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } } } } @@ -1009,44 +971,6 @@ private fun StoreSourceMenuItem( } } -@Composable -private fun StoreLaunchOptionRow( - label: String, - selected: Boolean, - enabled: Boolean = true, - onClick: () -> Unit, -) { - val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f) - Row( - modifier = - Modifier - .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) - .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { - if (selected) { - Icon( - Icons.Outlined.Check, - contentDescription = null, - tint = StoreAccent, - modifier = Modifier.size(16.dp), - ) - } - } - Text( - label, - color = if (selected) StoreAccent else contentColor, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 280.dp), - ) - } -} - @Composable private fun StoreStatChip( icon: ImageVector, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 7b6e1e0e4..c6424fba7 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -4336,6 +4336,7 @@ class UnifiedActivity : 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) } LaunchedEffect(app.id, isSteamLibraryGame) { @@ -5076,13 +5077,8 @@ class UnifiedActivity : (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, - launchOptions = launchOptions, - selectedLaunchOption = selectedLaunchOption, - onSelectLaunchOption = { option -> - persistSteamLaunchOptionSelection(app.id, option, scope) { - selectedLaunchOption = it - } - }, + showLaunchOptions = launchOptions.size >= 2, + onLaunchOptions = { showLaunchOptionsDialog = true }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -5508,6 +5504,20 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + if (showLaunchOptionsDialog) { + LaunchOptionsDialog( + gameTitle = app.name, + options = launchOptions, + selectedOption = selectedLaunchOption, + onSelect = { option -> + persistSteamLaunchOptionSelection(app.id, option, scope) { + selectedLaunchOption = it + } + }, + onDismissRequest = { showLaunchOptionsDialog = false }, + ) + } } } } @@ -8578,6 +8588,7 @@ 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 updateInfo by remember(app.id) { mutableStateOf(null) } @@ -8782,11 +8793,8 @@ class UnifiedActivity : showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, - launchOptions = launchOptions, - selectedLaunchOption = selectedLaunchOption, - onSelectLaunchOption = { option -> - persistSteamLaunchOptionSelection(app.id, option, scope) { selectedLaunchOption = it } - }, + showLaunchOptions = launchOptions.size >= 2, + onLaunchOptions = { showLaunchOptionsDialog = true }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, @@ -8925,6 +8933,45 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + if (showLaunchOptionsDialog) { + LaunchOptionsDialog( + gameTitle = app.name, + options = launchOptions, + selectedOption = selectedLaunchOption, + onSelect = { option -> + persistSteamLaunchOptionSelection(app.id, option, scope) { selectedLaunchOption = it } + }, + onDismissRequest = { showLaunchOptionsDialog = false }, + ) + } + } + + /** Hosts the Workshop-styled launch-option picker window over a game detail dialog. */ + @Composable + private fun LaunchOptionsDialog( + gameTitle: String, + options: List, + selectedOption: StoreLaunchOptionItem?, + onSelect: (StoreLaunchOptionItem) -> Unit, + onDismissRequest: () -> Unit, + ) { + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreLaunchOptionsScreen( + gameTitle = gameTitle, + options = options, + selectedOption = selectedOption, + onSelect = onSelect, + onClose = onDismissRequest, + ) + } } @Composable From cf4560da8ffc8184b4f1b3ffe020cf969d1b8bc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 10:54:46 +0000 Subject: [PATCH 08/21] review(steam): launch options cleanup pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a full-branch review applied: - PICS appinfo re-fetch now runs once per app per process (retried on failure) instead of on every game-detail open, and the two-phase load + refresh logic is shared by both dialogs via loadSteamLaunchOptionsRefreshing — drops a network round-trip and a duplicate shortcut scan from repeat opens. - LaunchOptionsDialog owns selection persistence (appId + onSelectionSaved), removing the duplicated persist lambda from both host sites. - XServerDisplayActivity: single combineWithSelectedLaunchArgs resolver replaces the getExtra+combine pattern repeated across the direct-launch and both ColdClient INI sites. - Steam-env stamp stores the raw effective LaunchOptions line instead of its hashCode — exact equality, no silent collision skips. - StoreLaunchOptionItem moved to LaunchOptionsScreen.kt where the picker lives. https://claude.ai/code/session_01EUsZxzAWCjSh8LYRS9W3vT --- app/src/main/app/shell/LaunchOptionsScreen.kt | 8 ++ .../main/app/shell/StoreGameDetailScreen.kt | 7 -- app/src/main/app/shell/UnifiedActivity.kt | 75 ++++++++++++------- .../display/XServerDisplayActivity.java | 24 ++++-- 4 files changed, 72 insertions(+), 42 deletions(-) diff --git a/app/src/main/app/shell/LaunchOptionsScreen.kt b/app/src/main/app/shell/LaunchOptionsScreen.kt index 8e89cdbb2..8309a381b 100644 --- a/app/src/main/app/shell/LaunchOptionsScreen.kt +++ b/app/src/main/app/shell/LaunchOptionsScreen.kt @@ -45,6 +45,14 @@ 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) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 3451bebab..f936b84a4 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -114,13 +114,6 @@ internal data class StoreDlcItem( val isInstalled: Boolean = false, ) -internal data class StoreLaunchOptionItem( - // Relative path, '/'-separated (appinfo config.launch entry). - val executable: String, - val arguments: String, - val label: String, -) - private val StoreBlack = Color.Black private val StoreCard = Color(0xFF12121B) private val StoreAccent = Color(0xFF1A9FFF) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index c6424fba7..db72c1178 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -4347,14 +4347,9 @@ class UnifiedActivity : selectedLaunchOption = null return@LaunchedEffect } - val (options, selected) = loadSteamLaunchOptions(app.id) - launchOptions = options - selectedLaunchOption = selected - // Heal cached appinfo rows that predate LaunchInfo.arguments. - if (SteamService.refreshAppInfoFromPics(app.id)) { - val (freshOptions, freshSelected) = loadSteamLaunchOptions(app.id) - launchOptions = freshOptions - selectedLaunchOption = freshSelected + loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> + launchOptions = options + selectedLaunchOption = selected } } @@ -5507,14 +5502,11 @@ class UnifiedActivity : if (showLaunchOptionsDialog) { LaunchOptionsDialog( + appId = app.id, gameTitle = app.name, options = launchOptions, selectedOption = selectedLaunchOption, - onSelect = { option -> - persistSteamLaunchOptionSelection(app.id, option, scope) { - selectedLaunchOption = it - } - }, + onSelectionSaved = { selectedLaunchOption = it }, onDismissRequest = { showLaunchOptionsDialog = false }, ) } @@ -8507,6 +8499,11 @@ class UnifiedActivity : } } + // Apps whose appinfo was already re-fetched this process (see + // loadSteamLaunchOptionsRefreshing) — avoids a PICS round-trip on every + // game-detail open. + private val launchOptionsRefreshedApps = 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. @@ -8541,6 +8538,28 @@ class UnifiedActivity : options to selected } + /** + * 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 (launchOptionsRefreshedApps.add(appId)) { + if (SteamService.refreshAppInfoFromPics(appId)) { + val (fresh, freshSelected) = loadSteamLaunchOptions(appId) + apply(fresh, freshSelected) + } else { + // Offline or fetch failed — allow a retry on the next open. + launchOptionsRefreshedApps.remove(appId) + } + } + } + private fun persistSteamLaunchOptionSelection( appId: Int, option: StoreLaunchOptionItem, @@ -8662,14 +8681,9 @@ class UnifiedActivity : selectedLaunchOption = null return@LaunchedEffect } - val (options, selected) = loadSteamLaunchOptions(app.id) - launchOptions = options - selectedLaunchOption = selected - // Heal cached appinfo rows that predate LaunchInfo.arguments. - if (SteamService.refreshAppInfoFromPics(app.id)) { - val (freshOptions, freshSelected) = loadSteamLaunchOptions(app.id) - launchOptions = freshOptions - selectedLaunchOption = freshSelected + loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> + launchOptions = options + selectedLaunchOption = selected } } @@ -8936,26 +8950,31 @@ class UnifiedActivity : if (showLaunchOptionsDialog) { LaunchOptionsDialog( + appId = app.id, gameTitle = app.name, options = launchOptions, selectedOption = selectedLaunchOption, - onSelect = { option -> - persistSteamLaunchOptionSelection(app.id, option, scope) { selectedLaunchOption = it } - }, + onSelectionSaved = { selectedLaunchOption = it }, onDismissRequest = { showLaunchOptionsDialog = false }, ) } } - /** Hosts the Workshop-styled launch-option picker window over a game detail dialog. */ + /** + * 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?, - onSelect: (StoreLaunchOptionItem) -> Unit, + onSelectionSaved: (StoreLaunchOptionItem) -> Unit, onDismissRequest: () -> Unit, ) { + val scope = rememberCoroutineScope() Dialog( onDismissRequest = onDismissRequest, properties = @@ -8968,7 +8987,9 @@ class UnifiedActivity : gameTitle = gameTitle, options = options, selectedOption = selectedOption, - onSelect = onSelect, + onSelect = { option -> + persistSteamLaunchOptionSelection(appId, option, scope, onSelectionSaved) + }, onClose = onDismissRequest, ) } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 14e9dd510..db1da82ba 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -6888,9 +6888,8 @@ 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 = SteamUtils.combineSteamLaunchArgs( - shortcut.getExtra("launch_exe_args"), - shortcut.getSettingExtra("execArgs", container.getExecArgs())); + String steamExtraArgs = + combineWithSelectedLaunchArgs(shortcut.getSettingExtra("execArgs", container.getExecArgs())); steamExtraArgs = !steamExtraArgs.isEmpty() ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); @@ -7277,8 +7276,7 @@ private void writeColdClientIniDirect(int appId, String gameDirName, String rela } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = SteamUtils.combineSteamLaunchArgs( - shortcut != null ? shortcut.getExtra("launch_exe_args") : "", perGameExecArgs); + String exeCommandLine = combineWithSelectedLaunchArgs(perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7354,8 +7352,7 @@ private void writeColdClientIniForLaunch(int appId, String gameInstallPath, Stri } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = SteamUtils.combineSteamLaunchArgs( - shortcut != null ? shortcut.getExtra("launch_exe_args") : "", perGameExecArgs); + String exeCommandLine = combineWithSelectedLaunchArgs(perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7480,6 +7477,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); @@ -9074,10 +9081,11 @@ private void setupSteamEnvironment(int appId, File gameDir) { 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.hashCode(); + + "|" + effectiveLaunchOptions; String existingStamp = steamEnvStamp.exists() ? FileUtils.readString(steamEnvStamp).trim() : ""; boolean steamEnvWarm = expectedStamp.equals(existingStamp); From 71b426347d88795c24d3d20a185382eabf9b30b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 11:55:08 +0000 Subject: [PATCH 09/21] feat(steam): beta branch selector in the STEAM dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Beta Branch" menu item to the STEAM ▾ dropdown on both game detail screens (StoreGameDetailScreen / LibraryGameLaunchScreen). Tapping it opens a Workshop-styled picker (BetaBranchScreen.kt) listing the game's PICS branches with the current one check-marked. Password-protected branches are shown disabled (no beta-password flow exists in this app). • BetaBranchScreen.kt — new Workshop-shaped modal; mirrors the structure of LaunchOptionsScreen.kt with AltRoute icon and per-file palette constants. • Menu item hidden when the game has only one branch (public), mirroring the >= 2 gate used by Launch Options. • Persistence: SteamService.setSelectedBetaBranch() stores the choice as the "selectedBranch" shortcut extra (blank = public/default); SteamService.resolveSelectedBetaName() refactored to reuse findSteamShortcut() instead of its own loadShortcuts() scan. • Downstream already honors the selection (resolveDepotManifestInfo, setAppCurrentBeta, ACF buildId); after a successful save the dialog calls startUpdateCheck() so the new branch's build downloads. • SteamUtils.writeCompleteSettingsDir: configs.app.ini [app::general] now writes is_beta_branch/branch_name from the actual selected branch instead of always hardcoding public, so gbe_fork's GetCurrentBetaName() / GetAppBuildId() return correct values. branches.json already lists all branches correctly — no change needed. • Appinfo refresh guard generalised: launchOptionsRefreshedApps renamed to appinfoRefreshedApps so one PICS round-trip per process feeds both the Launch Options and Beta Branch pickers. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- app/src/main/app/shell/BetaBranchScreen.kt | 261 ++++++++++++++++++ .../main/app/shell/LibraryGameLaunchScreen.kt | 14 + .../main/app/shell/StoreGameDetailScreen.kt | 18 +- app/src/main/app/shell/UnifiedActivity.kt | 148 +++++++++- .../stores/steam/service/SteamService.kt | 32 ++- .../feature/stores/steam/utils/SteamUtils.kt | 9 +- app/src/main/res/values/strings.xml | 2 + 7 files changed, 467 insertions(+), 17 deletions(-) create mode 100644 app/src/main/app/shell/BetaBranchScreen.kt diff --git a/app/src/main/app/shell/BetaBranchScreen.kt b/app/src/main/app/shell/BetaBranchScreen.kt new file mode 100644 index 000000000..51d9d953a --- /dev/null +++ b/app/src/main/app/shell/BetaBranchScreen.kt @@ -0,0 +1,261 @@ +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.AltRoute +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Lock +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.alpha +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 +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** A single Steam beta branch entry from appinfo depots.branches. */ +internal data class StoreBetaBranchItem( + val name: String, + val buildId: Long, + val timeUpdated: Date?, + val pwdRequired: Boolean, +) + +// Palette — mirrors the LaunchOptions / Workshop window so the modal feels native. +private val BbBg = Color(0xFF12121B) +private val BbBorder = Color(0xFF2A2A3A) +private val BbAccent = Color(0xFF1A9FFF) +private val BbAccentGlow = Color(0xFF58A6FF) +private val BbTextPrimary = Color(0xFFF0F4FF) +private val BbTextSecondary = Color(0xFF93A6BC) +private val BbScrim = Color(0xFF000000) +private val BbLocked = Color(0xFF505060) + +/** + * Steam beta-branch picker — a Workshop-shaped modal window listing the game's + * PICS depots.branches entries. Tapping an unlocked row persists the selection + * and triggers the update flow. Password-protected branches are shown as + * disabled (no beta-password support in this app). + * + * Stateless: data and callbacks are hoisted to the BetaBranchesDialog wrapper. + */ +@Composable +internal fun StoreBetaBranchScreen( + gameTitle: String, + branches: List, + 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 = { if (!branch.pwdRequired) 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 == "public") "${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 = 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/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index c035ff925..936541dfe 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Delete 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 @@ -135,6 +136,8 @@ internal fun LibraryGameLaunchScreen( showWorkshop: Boolean = true, showLaunchOptions: Boolean = false, onLaunchOptions: () -> Unit = {}, + showBetaBranches: Boolean = false, + onBetaBranches: () -> Unit = {}, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -272,11 +275,13 @@ internal fun LibraryGameLaunchScreen( showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, showLaunchOptions = showLaunchOptions, + showBetaBranches = showBetaBranches, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, onLaunchOptions = onLaunchOptions, + onBetaBranches = onBetaBranches, ) } @@ -801,11 +806,13 @@ private fun SourceTag( showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, showLaunchOptions: Boolean = false, + showBetaBranches: Boolean = false, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, onLaunchOptions: () -> Unit = {}, + onBetaBranches: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } @@ -883,6 +890,13 @@ private fun SourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onLaunchOptions() } } + if (showBetaBranches) { + 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 f936b84a4..789ef5f37 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -57,6 +57,7 @@ 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 @@ -152,6 +153,8 @@ internal fun StoreGameDetailScreen( areSteamActionsEnabled: Boolean = true, showLaunchOptions: Boolean = false, onLaunchOptions: () -> Unit = {}, + showBetaBranches: Boolean = false, + onBetaBranches: () -> Unit = {}, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -190,8 +193,10 @@ internal fun StoreGameDetailScreen( val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled val launchOptionsAvailable = showLaunchOptions && isInstalled + val betaBranchesAvailable = showBetaBranches && isInstalled val sourceMenuEnabled = - updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable + updateCheckAvailable || verifyFilesAvailable || workshopAvailable || + launchOptionsAvailable || betaBranchesAvailable val showDlcCard = dlcs.isNotEmpty() val showActionColumn = showDownloadCta || showUpdateCta || @@ -299,6 +304,7 @@ internal fun StoreGameDetailScreen( showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, showLaunchOptions = launchOptionsAvailable, + showBetaBranches = betaBranchesAvailable, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -310,6 +316,7 @@ internal fun StoreGameDetailScreen( onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, onLaunchOptions = onLaunchOptions, + onBetaBranches = onBetaBranches, ) } @@ -788,6 +795,7 @@ private fun StoreSourceTag( showVerifyFiles: Boolean = false, showWorkshop: Boolean = false, showLaunchOptions: Boolean = false, + showBetaBranches: Boolean = false, isCheckingForUpdate: Boolean = false, areSteamActionsEnabled: Boolean = true, isUpdateCheckEnabled: Boolean = true, @@ -795,6 +803,7 @@ private fun StoreSourceTag( onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, onLaunchOptions: () -> Unit = {}, + onBetaBranches: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } @@ -877,6 +886,13 @@ private fun StoreSourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onLaunchOptions() } } + if (showBetaBranches) { + StoreSourceMenuItem( + 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/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index db72c1178..57841344c 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -4339,18 +4339,27 @@ class UnifiedActivity : 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 } loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> launchOptions = options selectedLaunchOption = selected } + loadSteamBetaBranchesRefreshing(app.id) { branches, selected -> + betaBranches = branches + selectedBetaBranch = selected + } } val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( @@ -5074,6 +5083,8 @@ class UnifiedActivity : showWorkshop = !isEpic && !isGog, showLaunchOptions = launchOptions.size >= 2, onLaunchOptions = { showLaunchOptionsDialog = true }, + showBetaBranches = betaBranches.size >= 2, + onBetaBranches = { showBetaBranchesDialog = true }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -5510,6 +5521,18 @@ class UnifiedActivity : onDismissRequest = { showLaunchOptionsDialog = false }, ) } + + if (showBetaBranchesDialog) { + BetaBranchesDialog( + appId = app.id, + gameTitle = app.name, + branches = betaBranches, + selectedBranch = selectedBetaBranch, + onSelectionSaved = { selectedBetaBranch = it }, + onDismissRequest = { showBetaBranchesDialog = false }, + onStartUpdate = { startUpdateCheck(app.id, app.name) }, + ) + } } } } @@ -8500,9 +8523,9 @@ class UnifiedActivity : } // Apps whose appinfo was already re-fetched this process (see - // loadSteamLaunchOptionsRefreshing) — avoids a PICS round-trip on every - // game-detail open. - private val launchOptionsRefreshedApps = java.util.Collections.synchronizedSet(mutableSetOf()) + // loadSteamLaunchOptionsRefreshing / loadSteamBetaBranchesRefreshing) — + // 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 @@ -8549,13 +8572,13 @@ class UnifiedActivity : ) { val (options, selected) = loadSteamLaunchOptions(appId) apply(options, selected) - if (launchOptionsRefreshedApps.add(appId)) { + if (appinfoRefreshedApps.add(appId)) { if (SteamService.refreshAppInfoFromPics(appId)) { val (fresh, freshSelected) = loadSteamLaunchOptions(appId) apply(fresh, freshSelected) } else { // Offline or fetch failed — allow a retry on the next open. - launchOptionsRefreshedApps.remove(appId) + appinfoRefreshedApps.remove(appId) } } } @@ -8588,6 +8611,63 @@ class UnifiedActivity : } } + 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 != "public" }, { it.name })) + .orEmpty() + val selectedName = SteamService.resolveSelectedBetaName(appId).ifBlank { "public" } + val selected = + branches.firstOrNull { it.name == selectedName } + ?: branches.firstOrNull { it.name == "public" } + branches to selected + } + + private suspend fun loadSteamBetaBranchesRefreshing( + appId: Int, + apply: (List, StoreBetaBranchItem?) -> Unit, + ) { + val (branches, selected) = loadSteamBetaBranches(appId) + apply(branches, selected) + if (appinfoRefreshedApps.add(appId)) { + if (SteamService.refreshAppInfoFromPics(appId)) { + val (fresh, freshSelected) = loadSteamBetaBranches(appId) + apply(fresh, freshSelected) + } else { + appinfoRefreshedApps.remove(appId) + } + } + } + + private fun persistSteamBetaBranchSelection( + appId: Int, + item: StoreBetaBranchItem, + scope: CoroutineScope, + onSaved: (StoreBetaBranchItem) -> Unit, + startUpdate: () -> Unit, + ) { + scope.launch(Dispatchers.IO) { + val branchName = if (item.name == "public") "" 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( @@ -8610,6 +8690,9 @@ class UnifiedActivity : 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) } 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( @@ -8679,12 +8762,18 @@ class UnifiedActivity : if (installed != true) { launchOptions = emptyList() selectedLaunchOption = null + betaBranches = emptyList() + selectedBetaBranch = null return@LaunchedEffect } loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> launchOptions = options selectedLaunchOption = selected } + loadSteamBetaBranchesRefreshing(app.id) { branches, selected -> + betaBranches = branches + selectedBetaBranch = selected + } } val totalDownloadSize = selectedManifestSizes.downloadSize @@ -8809,6 +8898,8 @@ class UnifiedActivity : areSteamActionsEnabled = !hasBlockingSteamDownload, showLaunchOptions = launchOptions.size >= 2, onLaunchOptions = { showLaunchOptionsDialog = true }, + showBetaBranches = betaBranches.size >= 2, + onBetaBranches = { showBetaBranchesDialog = true }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, @@ -8958,6 +9049,18 @@ class UnifiedActivity : onDismissRequest = { showLaunchOptionsDialog = false }, ) } + + if (showBetaBranchesDialog) { + BetaBranchesDialog( + appId = app.id, + gameTitle = app.name, + branches = betaBranches, + selectedBranch = selectedBetaBranch, + onSelectionSaved = { selectedBetaBranch = it }, + onDismissRequest = { showBetaBranchesDialog = false }, + onStartUpdate = { startUpdateCheck(app.id, app.name) }, + ) + } } /** @@ -8995,6 +9098,41 @@ class UnifiedActivity : } } + /** + * Hosts the Workshop-styled beta-branch picker window over a game detail + * dialog. Selecting an unlocked row persists it and triggers [onStartUpdate]. + */ + @Composable + private fun BetaBranchesDialog( + appId: Int, + gameTitle: String, + branches: List, + selectedBranch: StoreBetaBranchItem?, + onSelectionSaved: (StoreBetaBranchItem) -> Unit, + onDismissRequest: () -> Unit, + onStartUpdate: () -> Unit, + ) { + val scope = rememberCoroutineScope() + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreBetaBranchScreen( + gameTitle = gameTitle, + branches = branches, + selectedBranch = selectedBranch, + onSelect = { item -> + persistSteamBetaBranchSelection(appId, item, scope, onSelectionSaved, onStartUpdate) + }, + onClose = onDismissRequest, + ) + } + } + @Composable private fun WorkshopDialog( appId: Int, diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 7f2501364..1ef3ce377 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -2362,6 +2362,30 @@ class SteamService : Service() { return exe.replace('\\', '/') to shortcut?.getExtra("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) @@ -5499,13 +5523,7 @@ 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 - } - "" + findSteamShortcut(svc, appId)?.getExtra("selectedBranch").orEmpty().trim() }.getOrElse { "" } } diff --git a/app/src/main/feature/stores/steam/utils/SteamUtils.kt b/app/src/main/feature/stores/steam/utils/SteamUtils.kt index acde7feec..38ec9d926 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 == "public") 0 else 1}") + appendLine("branch_name=$selectedBranch") appendLine() appendLine("[app::dlcs]") appendLine("unlock_all=0") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b80cfa53..9d598cab6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,6 +118,8 @@ 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 From ba8d085b645191cfe0ec34717d6bef5c9db94707 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 13:23:07 +0000 Subject: [PATCH 10/21] fix(steam): branch selector update check/download honour selected branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startUpdateCheck was calling checkForAppUpdate(appId) without a branch, so the comparison always ran against the public manifests and reported "no updates found" even after switching branches. The private downloadApp overload (routing downloadAppForUpdate) also hardcoded branch = "public", so the download itself would have fetched the wrong manifests even if the check had passed. Fix: resolve resolveSelectedBetaName before the update check and pass it to checkForAppUpdate; resolve it again inside the private downloadApp overload instead of hardcoding "public". Fresh installs (no shortcut yet) resolve to "" → "public" unchanged. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- app/src/main/app/shell/UnifiedActivity.kt | 5 ++++- app/src/main/feature/stores/steam/service/SteamService.kt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 57841344c..730589bb9 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -371,7 +371,10 @@ class UnifiedActivity : lifecycleScope.launch { val result = runCatching { - withContext(Dispatchers.IO) { SteamService.checkForAppUpdate(appId) } + withContext(Dispatchers.IO) { + val branch = SteamService.resolveSelectedBetaName(appId).ifBlank { "public" } + SteamService.checkForAppUpdate(appId, branch) + } }.getOrNull() try { when { diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 1ef3ce377..6bc2052cf 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -2713,7 +2713,7 @@ class SteamService : Service() { appId = appId, downloadableDepots = downloadableDepots, userSelectedDlcAppIds = effectiveDlcAppIds, - branch = "public", + branch = resolveSelectedBetaName(appId).ifBlank { "public" }, includeInstalledDepots = includeInstalledDepots, enableVerify = enableVerify, allowPersistedProgress = allowPersistedProgress, From 658a5b11d58b26a212ea0f8698890aa26055fa38 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:24:11 +0000 Subject: [PATCH 11/21] =?UTF-8?q?fix(steam):=20lightweight=20selectedBranc?= =?UTF-8?q?h=20read=20=E2=80=94=20stop=20loadShortcuts=20in=20download=20h?= =?UTF-8?q?ot=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking install/check-update crashed after the branch-selector fix: resolveSelectedBetaName ran ContainerManager().loadShortcuts(), whose Shortcut constructor decodes full cover-art bitmaps for every shortcut, runs PE icon extraction over game exes and can rewrite .desktop files. Doing that on every download/update click spikes the heap (OOM lands on whatever thread allocates next — runCatching can't save the app) and races the UI's own .desktop reads. resolveSelectedBetaName now parses the [Extra Data] section of the .desktop files directly — no Shortcut construction, no bitmap decode, no writes. The heavyweight findSteamShortcut path remains only in the one-off setter (setSelectedBetaBranch). Also: • checkForAppUpdate / isUpdatePending default their branch parameter to the game's selected beta, so every caller (startUpdateCheck, the store dialog's onDownloadUpdate) is branch-aware without per-site resolution; the UnifiedActivity call site is back to one line. • startUpdateCheck wraps downloadAppForUpdate in runCatching — a failed update start now shows the failure popup instead of crashing. • completeAppDownload prunes stale "{depotId}_{gid}.manifest" caches not matching depot.config — branch switches no longer accumulate old manifests, and checkForAppUpdate's cache fallback can't mistake a previously-downloaded branch for the installed one. Legacy installs without depot.config are left untouched. • BetaBranchScreen: remember() the formatted date; ktlint-safe wrapping. Known limitation (documented, native-side): the Rust depot writer does not delete game files absent from the new branch's manifest, so files removed between branches linger until a fresh install. Fixing that needs a manifest-diff pass in wn-steam-client. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- app/src/main/app/shell/BetaBranchScreen.kt | 10 ++- app/src/main/app/shell/UnifiedActivity.kt | 18 +++-- .../stores/steam/service/SteamService.kt | 76 +++++++++++++++++-- 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/app/src/main/app/shell/BetaBranchScreen.kt b/app/src/main/app/shell/BetaBranchScreen.kt index 51d9d953a..c4a5bc08a 100644 --- a/app/src/main/app/shell/BetaBranchScreen.kt +++ b/app/src/main/app/shell/BetaBranchScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -232,9 +233,12 @@ private fun BetaBranchPickerRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - val dateStr = branch.timeUpdated - ?.let { SimpleDateFormat("MMM d, yyyy", Locale.US).format(it) } - ?: "—" + val dateStr = + remember(branch.timeUpdated) { + branch.timeUpdated + ?.let { SimpleDateFormat("MMM d, yyyy", Locale.US).format(it) } + ?: "—" + } Text( "build ${branch.buildId} · $dateStr", color = BbTextSecondary, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 730589bb9..cea0c966d 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -371,10 +371,8 @@ class UnifiedActivity : lifecycleScope.launch { val result = runCatching { - withContext(Dispatchers.IO) { - val branch = SteamService.resolveSelectedBetaName(appId).ifBlank { "public" } - SteamService.checkForAppUpdate(appId, branch) - } + // checkForAppUpdate defaults to the game's selected beta branch. + withContext(Dispatchers.IO) { SteamService.checkForAppUpdate(appId) } }.getOrNull() try { when { @@ -385,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( diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 6bc2052cf..2230a0734 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -4538,6 +4538,28 @@ class SteamService : Service() { runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_PATCHED) } runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_UNPACK_CHECKED) } + // Prune stale "{depotId}_{gid}.manifest" caches: after a branch + // switch or ordinary update the previous build's manifests linger, + // wasting disk and — when depot.config is missing an entry — + // letting checkForAppUpdate's cache fallback mistake an old build + // for installed. Keep only what depot.config says is current; skip + // legacy installs with no depot.config, whose fallback needs them. + runCatching { + val installedManifests = readInstalledDepotManifestIds(appDirPath) + if (installedManifests.isNotEmpty()) { + File(appDirPath, ".DepotDownloader") + .listFiles { f -> f.isFile && f.name.endsWith(".manifest") } + ?.forEach { f -> + val parts = f.name.removeSuffix(".manifest").split('_') + val depotId = parts.getOrNull(0)?.toIntOrNull() ?: return@forEach + val gid = parts.getOrNull(1)?.toLongOrNull() ?: return@forEach + if (parts.size == 2 && 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") } + // Same reason as the runCatching above: a Room exception // here used to FAIL a fully-downloaded game with COMPLETE // marker already on disk. @@ -5522,9 +5544,53 @@ class SteamService : Service() { fun resolveSelectedBetaName(appId: Int): String { if (appId <= 0) return "" val svc = instance ?: return "" - return runCatching { - findSteamShortcut(svc, appId)?.getExtra("selectedBranch").orEmpty().trim() - }.getOrElse { "" } + return runCatching { readSelectedBranchExtra(svc, appId).trim() }.getOrElse { "" } + } + + /** + * Reads the `selectedBranch` extra straight from the Steam shortcut's + * .desktop file. Deliberately NOT findSteamShortcut/loadShortcuts: the + * Shortcut constructor decodes cover-art bitmaps, runs PE icon extraction + * and can rewrite .desktop files — far too heavy (and write-racy) for the + * download/update-check hot paths that only need one string. + */ + private fun readSelectedBranchExtra( + context: Context, + appId: Int, + ): String { + val appIdStr = appId.toString() + val homeDir = File(ImageFs.find(context).rootDir, "home") + val userDirs = + homeDir.listFiles { f -> f.isDirectory && f.name.startsWith("${ImageFs.USER}-") } + ?: return "" + for (userDir in userDirs) { + val desktopDir = File(userDir, ".wine/drive_c/users/${ImageFs.USER}/Desktop") + val files = desktopDir.listFiles { f -> f.name.endsWith(".desktop") } ?: continue + for (file in files) { + var section = "" + var gameSource = "" + var fileAppId = "" + var branch = "" + for (raw in file.readLines()) { + val line = raw.trim() + if (line.isEmpty() || line.startsWith("#")) continue + if (line.startsWith("[")) { + section = line.substringAfter("[").substringBefore("]") + continue + } + if (section != "Extra Data") continue + when (line.substringBefore("=", "")) { + "game_source" -> gameSource = line.substringAfter("=") + "app_id" -> fileAppId = line.substringAfter("=") + "selectedBranch" -> branch = line.substringAfter("=") + } + } + if (gameSource == "STEAM" && fileAppId == appIdStr && branch.isNotEmpty()) { + return branch + } + } + } + return "" } suspend fun refreshEncryptedAppTicketForLibSteamClient(appId: Int): Boolean { @@ -7385,12 +7451,12 @@ class SteamService : Service() { suspend fun isUpdatePending( appId: Int, - branch: String = "public", + branch: String = resolveSelectedBetaName(appId).ifBlank { "public" }, ): Boolean = checkForAppUpdate(appId, branch).hasUpdate suspend fun checkForAppUpdate( appId: Int, - branch: String = "public", + branch: String = resolveSelectedBetaName(appId).ifBlank { "public" }, ): SteamUpdateInfo = withContext(Dispatchers.IO) { fun SteamUpdateInfo.logged(): SteamUpdateInfo { From d837242447c4a591b74d5fe40ad081822955cbe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:33:14 +0000 Subject: [PATCH 12/21] =?UTF-8?q?review(steam):=20beta=20branch=20selector?= =?UTF-8?q?=20=E2=80=94=20multi-angle=20review=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied from a seven-angle review pass (line-scan, removed-behavior, cross-file tracing, reuse, simplification, efficiency, altitude): • "public" comparisons now case-insensitive (equals ignoreCase), matching the existing convention in branches.json / manifest resolution — covers the row label, sort order, selection fallback, blank-mapping on save, and configs.app.ini is_beta_branch. • Branch picker closes after a successful save so the update-check popup it triggers is what the user sees next, not a stale window. • Install/DLC size estimates in GameManagerDialog are sized against the selected branch (resolved once per load) — previously the cards showed public-branch sizes while the download fetched the beta. • readSelectedBranchExtra uses FileUtils.readLines (same reader as Shortcut's parser): one corrupt .desktop file no longer aborts the scan of remaining containers. • Stale-manifest prune extracted to pruneStaleDepotManifestCache next to the other cleanup helpers. • Store onInstall download start wrapped in runCatching — an exception there killed the app (plain coroutine launch, no handler). • Dropped the redundant pwdRequired guard in the picker row callback; the row-level clickable gate already enforces it. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- app/src/main/app/shell/BetaBranchScreen.kt | 16 ++++-- app/src/main/app/shell/UnifiedActivity.kt | 41 +++++++++++--- .../stores/steam/service/SteamService.kt | 55 +++++++++++-------- .../feature/stores/steam/utils/SteamUtils.kt | 2 +- 4 files changed, 77 insertions(+), 37 deletions(-) diff --git a/app/src/main/app/shell/BetaBranchScreen.kt b/app/src/main/app/shell/BetaBranchScreen.kt index c4a5bc08a..82ce226c5 100644 --- a/app/src/main/app/shell/BetaBranchScreen.kt +++ b/app/src/main/app/shell/BetaBranchScreen.kt @@ -71,9 +71,10 @@ private val BbLocked = Color(0xFF505060) /** * Steam beta-branch picker — a Workshop-shaped modal window listing the game's - * PICS depots.branches entries. Tapping an unlocked row persists the selection - * and triggers the update flow. Password-protected branches are shown as - * disabled (no beta-password support in this app). + * PICS depots.branches entries. Tapping an unlocked row persists the selection, + * closes the window and triggers the update flow so the branch's build actually + * downloads. Password-protected branches are shown as disabled (no + * beta-password support in this app). * * Stateless: data and callbacks are hoisted to the BetaBranchesDialog wrapper. */ @@ -121,7 +122,7 @@ internal fun StoreBetaBranchScreen( BetaBranchPickerRow( branch = branch, selected = branch == selectedBranch, - onClick = { if (!branch.pwdRequired) onSelect(branch) }, + onClick = { onSelect(branch) }, ) if (index < branches.lastIndex) { HorizontalDivider( @@ -224,7 +225,12 @@ private fun BetaBranchPickerRow( horizontalArrangement = Arrangement.spacedBy(11.dp), ) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - val displayName = if (branch.name == "public") "${branch.name} (default)" else branch.name + val displayName = + if (branch.name.equals("public", ignoreCase = true)) { + "${branch.name} (default)" + } else { + branch.name + } Text( displayName, color = if (selected) BbAccentGlow else BbTextPrimary, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index cea0c966d..ebf2189b9 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -8627,12 +8627,13 @@ class UnifiedActivity : ?.branches ?.values ?.map { b -> StoreBetaBranchItem(b.name, b.buildId, b.timeUpdated, b.pwdRequired) } - ?.sortedWith(compareBy({ it.name != "public" }, { it.name })) + ?.sortedWith(compareBy({ !it.name.equals("public", ignoreCase = true) }, { it.name })) .orEmpty() val selectedName = SteamService.resolveSelectedBetaName(appId).ifBlank { "public" } val selected = - branches.firstOrNull { it.name == selectedName } - ?: branches.firstOrNull { it.name == "public" } + 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 } @@ -8660,7 +8661,9 @@ class UnifiedActivity : startUpdate: () -> Unit, ) { scope.launch(Dispatchers.IO) { - val branchName = if (item.name == "public") "" else item.name + // "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) { @@ -8734,10 +8737,12 @@ class UnifiedActivity : LaunchedEffect(app.id, downloadRecords) { val loadData = withContext(Dispatchers.IO) { + // Size the same branch the download will actually fetch. + val branch = 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) @@ -8747,7 +8752,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), ) } @@ -8763,7 +8768,8 @@ class UnifiedActivity : LaunchedEffect(app.id, selectedDlcIds.toList()) { selectedManifestSizes = withContext(Dispatchers.IO) { - SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList()) + val branch = SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } + SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList(), branch = branch) } } @@ -8927,7 +8933,13 @@ class UnifiedActivity : val installableDlcIds = dlcItems .filter { !it.isInstalled && it.id in selectedDlcIds } .map { it.id } - SteamService.downloadApp(app.id, installableDlcIds, false, customPath) + // An exception here is an app crash (plain launch, no + // handler) — surface it as a failed start instead. + runCatching { + SteamService.downloadApp(app.id, installableDlcIds, false, customPath) + }.onFailure { e -> + Log.w("UnifiedActivity", "Steam download failed to start for appId=${app.id}", e) + } withContext(Dispatchers.Main) { onDismissRequest() } } } @@ -9135,7 +9147,18 @@ class UnifiedActivity : branches = branches, selectedBranch = selectedBranch, onSelect = { item -> - persistSteamBetaBranchSelection(appId, item, scope, onSelectionSaved, onStartUpdate) + persistSteamBetaBranchSelection( + appId = appId, + item = item, + scope = scope, + onSaved = { saved -> + onSelectionSaved(saved) + // Close the picker so the update-check flow it triggers + // is what the user sees next, not a stale window. + onDismissRequest() + }, + startUpdate = onStartUpdate, + ) }, onClose = onDismissRequest, ) diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 2230a0734..b5173abd9 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -92,6 +92,7 @@ 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.io.FileUtils import com.winlator.cmod.shared.ui.toast.WinToast import com.winlator.cmod.shared.android.NotificationHelper import com.winlator.cmod.shared.io.StorageUtils @@ -4538,27 +4539,7 @@ class SteamService : Service() { runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_PATCHED) } runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_UNPACK_CHECKED) } - // Prune stale "{depotId}_{gid}.manifest" caches: after a branch - // switch or ordinary update the previous build's manifests linger, - // wasting disk and — when depot.config is missing an entry — - // letting checkForAppUpdate's cache fallback mistake an old build - // for installed. Keep only what depot.config says is current; skip - // legacy installs with no depot.config, whose fallback needs them. - runCatching { - val installedManifests = readInstalledDepotManifestIds(appDirPath) - if (installedManifests.isNotEmpty()) { - File(appDirPath, ".DepotDownloader") - .listFiles { f -> f.isFile && f.name.endsWith(".manifest") } - ?.forEach { f -> - val parts = f.name.removeSuffix(".manifest").split('_') - val depotId = parts.getOrNull(0)?.toIntOrNull() ?: return@forEach - val gid = parts.getOrNull(1)?.toLongOrNull() ?: return@forEach - if (parts.size == 2 && 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") } + pruneStaleDepotManifestCache(appDirPath) // Same reason as the runCatching above: a Room exception // here used to FAIL a fully-downloaded game with COMPLETE @@ -5571,7 +5552,10 @@ class SteamService : Service() { var gameSource = "" var fileAppId = "" var branch = "" - for (raw in file.readLines()) { + // 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("[")) { @@ -7627,6 +7611,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) diff --git a/app/src/main/feature/stores/steam/utils/SteamUtils.kt b/app/src/main/feature/stores/steam/utils/SteamUtils.kt index 38ec9d926..a854be1c7 100644 --- a/app/src/main/feature/stores/steam/utils/SteamUtils.kt +++ b/app/src/main/feature/stores/steam/utils/SteamUtils.kt @@ -1185,7 +1185,7 @@ object SteamUtils { // [app::general] — communicate the active branch to gbe_fork so // GetCurrentBetaName() and GetAppBuildId() return correct values. appendLine("[app::general]") - appendLine("is_beta_branch=${if (selectedBranch == "public") 0 else 1}") + appendLine("is_beta_branch=${if (selectedBranch.equals("public", ignoreCase = true)) 0 else 1}") appendLine("branch_name=$selectedBranch") appendLine() appendLine("[app::dlcs]") From c002d6f63a1d81c984b0e32b4920fdf5bd34572f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:22:53 +0000 Subject: [PATCH 13/21] perf(steam): read launch-option selection without constructing Shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSelectedLaunchOption ran ContainerManager().loadShortcuts() on every game-detail open. Each Shortcut constructed that way decodes the full cover-art bitmap, runs PE icon extraction over the game exe and can rewrite .desktop files — a large transient heap spike and a write race against the UI's own shortcut reads, just to fetch two strings. New readSteamShortcutExtras parses the [Extra Data] section straight from the .desktop files and returns the owning container dir; the container.executablePath fallback is read from the .container config JSON directly. The heavyweight findSteamShortcut path remains only in setSelectedLaunchOption, which must materialize the shortcut to write. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../stores/steam/service/SteamService.kt | 71 +++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 7f2501364..1e4c200ef 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -94,6 +94,7 @@ 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 dagger.hilt.android.AndroidEntryPoint import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag @@ -2313,6 +2314,60 @@ class SteamService : Service() { 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: @@ -2348,18 +2403,24 @@ class SteamService : Service() { /** * Currently effective launch option as (executable, arguments), resolved in the - * same order the launch path uses. Call on an IO dispatcher. + * 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 shortcut = findSteamShortcut(context, appId) + val found = runCatching { readSteamShortcutExtras(context, appId) }.getOrNull() + val extras = found?.first.orEmpty() val exe = - shortcut?.getExtra("launch_exe_path").orEmpty().ifBlank { - shortcut?.container?.executablePath.orEmpty().ifBlank { getInstalledExe(appId) } + extras["launch_exe_path"].orEmpty().ifBlank { + found?.second?.let { readContainerExecutablePath(it) }.orEmpty().ifBlank { + getInstalledExe(appId) + } } - return exe.replace('\\', '/') to shortcut?.getExtra("launch_exe_args").orEmpty() + return exe.replace('\\', '/') to extras["launch_exe_args"].orEmpty() } suspend fun deleteApp(appId: Int): Boolean = From 950f2c88724e0465032a71a1ecc6c50d5f66ebf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:26:56 +0000 Subject: [PATCH 14/21] fix(steam): crash-proof the store dialog's data-loading effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Steam store tab crashed on game tap while the library detail worked. GameManagerDialog runs three data-loading LaunchedEffects the library dialog doesn't all share — and an exception inside a LaunchedEffect tears down the whole Activity. All data-loading effects in GameManagerDialog and the shared launch-options/beta-branches loader effects now degrade on failure (logged, menu items hidden / zero sizes) instead of crashing, with CancellationException rethrown so effect restarts still work. Also dedupes the merged shortcut readers: resolveSelectedBetaName now uses readSteamShortcutExtras from the launch-options branch (which gained the same lightweight .desktop reader for getSelectedLaunchOption) and the selectedBranch-specific copy is gone. Read and write paths now agree on "first STEAM shortcut for the appId". https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- app/src/main/app/shell/UnifiedActivity.kt | 95 ++++++++++++------- .../stores/steam/service/SteamService.kt | 53 +---------- 2 files changed, 66 insertions(+), 82 deletions(-) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index ebf2189b9..dccd5deb5 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -4361,13 +4361,19 @@ class UnifiedActivity : selectedBetaBranch = null return@LaunchedEffect } - loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> - launchOptions = options - selectedLaunchOption = selected - } - loadSteamBetaBranchesRefreshing(app.id) { branches, selected -> - betaBranches = branches - selectedBetaBranch = selected + // 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) } } @@ -8735,25 +8741,39 @@ class UnifiedActivity : ) LaunchedEffect(app.id, downloadRecords) { + // An exception in this effect tears down the whole Activity — degrade + // to an empty load (logged) instead of crashing the store screen. val loadData = - withContext(Dispatchers.IO) { - // Size the same branch the download will actually fetch. - val branch = 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, branch = branch) - } - val installedDlcIds = - SteamService.getInstalledDlcDepotsOf(app.id) - .orEmpty() - .toSet() + runCatching { + withContext(Dispatchers.IO) { + // Size the same branch the download will actually fetch. + val branch = 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, branch = branch) + } + val installedDlcIds = + SteamService.getInstalledDlcDepotsOf(app.id) + .orEmpty() + .toSet() + SteamInstallLoadData( + dlcApps = selectableDlcApps, + dlcSizes = perDlcSizes, + installedDlcIds = installedDlcIds, + baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id, branch = branch), + installed = SteamService.isAppInstalled(app.id), + ) + } + }.getOrElse { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + Log.e("UnifiedActivity", "Steam install data load failed for appId=${app.id}", e) SteamInstallLoadData( - dlcApps = selectableDlcApps, - dlcSizes = perDlcSizes, - installedDlcIds = installedDlcIds, - baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id, branch = branch), - installed = SteamService.isAppInstalled(app.id), + dlcApps = emptyList(), + dlcSizes = emptyMap(), + installedDlcIds = emptySet(), + baseManifestSizes = SteamService.ManifestSizes(), + installed = runCatching { withContext(Dispatchers.IO) { SteamService.isAppInstalled(app.id) } }.getOrDefault(false), ) } dlcApps = loadData.dlcApps @@ -8766,11 +8786,16 @@ class UnifiedActivity : } LaunchedEffect(app.id, selectedDlcIds.toList()) { - selectedManifestSizes = + runCatching { withContext(Dispatchers.IO) { val branch = SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList(), branch = branch) } + }.onSuccess { selectedManifestSizes = it } + .onFailure { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + Log.e("UnifiedActivity", "Steam DLC size load failed for appId=${app.id}", e) + } } LaunchedEffect(app.id, installed) { @@ -8781,13 +8806,19 @@ class UnifiedActivity : selectedBetaBranch = null return@LaunchedEffect } - loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> - launchOptions = options - selectedLaunchOption = selected - } - loadSteamBetaBranchesRefreshing(app.id) { branches, selected -> - betaBranches = branches - selectedBetaBranch = selected + // 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) } } diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 1a1ed704a..69d0aa473 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -5586,56 +5586,9 @@ class SteamService : Service() { fun resolveSelectedBetaName(appId: Int): String { if (appId <= 0) return "" val svc = instance ?: return "" - return runCatching { readSelectedBranchExtra(svc, appId).trim() }.getOrElse { "" } - } - - /** - * Reads the `selectedBranch` extra straight from the Steam shortcut's - * .desktop file. Deliberately NOT findSteamShortcut/loadShortcuts: the - * Shortcut constructor decodes cover-art bitmaps, runs PE icon extraction - * and can rewrite .desktop files — far too heavy (and write-racy) for the - * download/update-check hot paths that only need one string. - */ - private fun readSelectedBranchExtra( - context: Context, - appId: Int, - ): String { - val appIdStr = appId.toString() - val homeDir = File(ImageFs.find(context).rootDir, "home") - val userDirs = - homeDir.listFiles { f -> f.isDirectory && f.name.startsWith("${ImageFs.USER}-") } - ?: return "" - for (userDir in userDirs) { - val desktopDir = File(userDir, ".wine/drive_c/users/${ImageFs.USER}/Desktop") - val files = desktopDir.listFiles { f -> f.name.endsWith(".desktop") } ?: continue - for (file in files) { - var section = "" - var gameSource = "" - var fileAppId = "" - var branch = "" - // 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 - when (line.substringBefore("=", "")) { - "game_source" -> gameSource = line.substringAfter("=") - "app_id" -> fileAppId = line.substringAfter("=") - "selectedBranch" -> branch = line.substringAfter("=") - } - } - if (gameSource == "STEAM" && fileAppId == appIdStr && branch.isNotEmpty()) { - return branch - } - } - } - return "" + return runCatching { + readSteamShortcutExtras(svc, appId)?.first?.get("selectedBranch").orEmpty().trim() + }.getOrElse { "" } } suspend fun refreshEncryptedAppTicketForLibSteamClient(appId: Int): Boolean { From f5ee2cfe4c67fcb1662f1b8eddb0348e34db2bf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:42:02 +0000 Subject: [PATCH 15/21] =?UTF-8?q?fix(steam):=20VerifyError=20on=20store=20?= =?UTF-8?q?game=20tap=20=E2=80=94=20shrink=20StoreGameDetailScreen=20signa?= =?UTF-8?q?ture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store-tab crash was never a logic bug: ART's bytecode verifier rejected StoreGameDetailScreenKt itself — VerifyError: [0x17BC] wide register v5 has type Low-half Constant/Zero/null a D8/Compose codegen defect triggered by the composable's huge parameter list (≈44 params incl. several longs + default masks). The launch-options build, two parameters smaller, verified fine on the same device; adding showBetaBranches/onBetaBranches pushed the generated default-argument handler over the edge. The class failed to load the moment the store dialog composed it — which is why it crashed instantly, for every game, store tab only, and no runCatching could help. Fix: fold each show-flag + callback pair into a single nullable callback (null = menu item hidden) in StoreGameDetailScreen, LibraryGameLaunchScreen and their source-tag menus. That returns StoreGameDetailScreen to exactly the parameter count that demonstrably passes the verifier, with a slightly cleaner API. Also fixes the PR CI compile failure: the merge had interleaved a duplicate com.winlator.cmod.shared.io.FileUtils import in SteamService.kt ("Conflicting import: name is ambiguous"). https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../main/app/shell/LibraryGameLaunchScreen.kt | 21 ++++++------ .../main/app/shell/StoreGameDetailScreen.kt | 31 +++++++++--------- app/src/main/app/shell/UnifiedActivity.kt | 32 ++++++++++++++----- .../stores/steam/service/SteamService.kt | 3 +- 4 files changed, 49 insertions(+), 38 deletions(-) diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 936541dfe..357ac4b36 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -134,10 +134,10 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, - showLaunchOptions: Boolean = false, - onLaunchOptions: () -> Unit = {}, - showBetaBranches: Boolean = false, - onBetaBranches: () -> Unit = {}, + // Nullable on purpose: null hides the menu item (mirrors StoreGameDetailScreen, + // whose signature size tripped ART's bytecode verifier with separate flags). + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -274,8 +274,6 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles = showVerifyFiles, showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, - showLaunchOptions = showLaunchOptions, - showBetaBranches = showBetaBranches, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, @@ -805,14 +803,13 @@ private fun SourceTag( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, - showLaunchOptions: Boolean = false, - showBetaBranches: Boolean = false, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - onLaunchOptions: () -> Unit = {}, - onBetaBranches: () -> Unit = {}, + // null hides the menu item (see LibraryGameLaunchScreen's signature note). + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } @@ -883,14 +880,14 @@ private fun SourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onWorkshop() } } - if (showLaunchOptions) { + if (onLaunchOptions != null) { LaunchSourceMenuItem( icon = Icons.Outlined.RocketLaunch, label = stringResource(R.string.store_game_launch_options), enabled = areSteamActionsEnabled, ) { menuOpen = false; onLaunchOptions() } } - if (showBetaBranches) { + if (onBetaBranches != null) { LaunchSourceMenuItem( icon = Icons.Outlined.AltRoute, label = stringResource(R.string.store_game_beta_branch), diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 789ef5f37..4dacae1ad 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -151,10 +151,12 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, - showLaunchOptions: Boolean = false, - onLaunchOptions: () -> Unit = {}, - showBetaBranches: Boolean = false, - onBetaBranches: () -> Unit = {}, + // Nullable on purpose: null hides the menu item. Folding show+callback into + // one parameter keeps this signature at the size that still passes ART's + // bytecode verifier — at ~44 params D8 emitted a default-handler this + // device's verifier rejected (VerifyError: wide register Low-half). + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -192,8 +194,8 @@ internal fun StoreGameDetailScreen( val showUpdateCta = updateCheckAvailable && isUpdateAvailable val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled - val launchOptionsAvailable = showLaunchOptions && isInstalled - val betaBranchesAvailable = showBetaBranches && isInstalled + val launchOptionsAvailable = onLaunchOptions != null && isInstalled + val betaBranchesAvailable = onBetaBranches != null && isInstalled val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable || betaBranchesAvailable @@ -303,8 +305,6 @@ internal fun StoreGameDetailScreen( showCheckForUpdate = updateCheckAvailable, showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, - showLaunchOptions = launchOptionsAvailable, - showBetaBranches = betaBranchesAvailable, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -315,8 +315,8 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, - onLaunchOptions = onLaunchOptions, - onBetaBranches = onBetaBranches, + onLaunchOptions = if (launchOptionsAvailable) onLaunchOptions else null, + onBetaBranches = if (betaBranchesAvailable) onBetaBranches else null, ) } @@ -794,16 +794,15 @@ private fun StoreSourceTag( showCheckForUpdate: Boolean = false, showVerifyFiles: Boolean = false, showWorkshop: Boolean = false, - showLaunchOptions: Boolean = false, - showBetaBranches: Boolean = false, isCheckingForUpdate: Boolean = false, areSteamActionsEnabled: Boolean = true, isUpdateCheckEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - onLaunchOptions: () -> Unit = {}, - onBetaBranches: () -> Unit = {}, + // null hides the menu item (see StoreGameDetailScreen's signature note). + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } @@ -879,14 +878,14 @@ private fun StoreSourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onWorkshop() } } - if (showLaunchOptions) { + if (onLaunchOptions != null) { StoreSourceMenuItem( icon = Icons.Outlined.RocketLaunch, label = stringResource(R.string.store_game_launch_options), enabled = areSteamActionsEnabled, ) { menuOpen = false; onLaunchOptions() } } - if (showBetaBranches) { + if (onBetaBranches != null) { StoreSourceMenuItem( icon = Icons.Outlined.AltRoute, label = stringResource(R.string.store_game_beta_branch), diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index dccd5deb5..5039bdedf 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -5096,10 +5096,18 @@ class UnifiedActivity : (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, - showLaunchOptions = launchOptions.size >= 2, - onLaunchOptions = { showLaunchOptionsDialog = true }, - showBetaBranches = betaBranches.size >= 2, - onBetaBranches = { showBetaBranchesDialog = true }, + onLaunchOptions = + if (launchOptions.size >= 2) { + { showLaunchOptionsDialog = true } + } else { + null + }, + onBetaBranches = + if (betaBranches.size >= 2) { + { showBetaBranchesDialog = true } + } else { + null + }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -8942,10 +8950,18 @@ class UnifiedActivity : showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, - showLaunchOptions = launchOptions.size >= 2, - onLaunchOptions = { showLaunchOptionsDialog = true }, - showBetaBranches = betaBranches.size >= 2, - onBetaBranches = { showBetaBranchesDialog = true }, + 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, diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 69d0aa473..756e41240 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -92,11 +92,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.io.FileUtils -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 From a8aabc3563832fc3b088485129fc24753c364f89 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 22:10:35 +0000 Subject: [PATCH 16/21] fix(steam): split StoreGameDetailScreen prologue from body for ART verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second VerifyError from the same device, different instruction: [0x1749] copy-cat1 v0<-v260 type=Reference: WindowInsets v260 is the tell: with the 40+-param defaulted signature and the whole screen body compiled into one method, the frame needed 260+ registers, and at that pressure D8's prologue emits register copies this device's ART verifier rejects. Reshaping parameters (previous commit) just moved the failure — the giant shared frame is the trigger. Fix splits the two pressure sources into separate methods: • StoreGameDetailScreen keeps the exact public defaulted signature but its body is now only "pack args into StoreGameDetailParams, call content" — a small frame containing the default-mask prologue. • StoreGameDetailContent(p) holds the original body behind local rebinds (body text unchanged) — a big body but a one-param method with no default machinery, so the frame stays well under the high-register regime. Call sites are untouched; behavior is identical. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../main/app/shell/StoreGameDetailScreen.kt | 152 +++++++++++++++++- 1 file changed, 148 insertions(+), 4 deletions(-) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 4dacae1ad..89bbe081d 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -123,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, @@ -151,10 +204,7 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, - // Nullable on purpose: null hides the menu item. Folding show+callback into - // one parameter keeps this signature at the size that still passes ART's - // bytecode verifier — at ~44 params D8 emitted a default-handler this - // device's verifier rejected (VerifyError: wide register Low-half). + // Nullable on purpose: null hides the menu item. onLaunchOptions: (() -> Unit)? = null, onBetaBranches: (() -> Unit)? = null, dlcs: List = emptyList(), @@ -172,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) } From b75d980a861f8131f06ce42cff14d280f9ac8964 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 02:57:19 +0000 Subject: [PATCH 17/21] review(steam): final cleanup pass over launch options + beta branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debloat from the crash investigation, now that the real cause (VerifyError, fixed by the wrapper/content split) is known: • Reverted the speculative runCatching guards added while hunting blind — GameManagerDialog's install-data load, the DLC size effect and the onInstall download call are back to the unguarded house style they had on main. Kept the two intentional guards: the launch-options/beta loader effects (network-dependent PICS refresh; failure should hide menu items, not kill the dialog) and startUpdateCheck's downloadAppForUpdate (our feature's primary flow). • Extracted refreshAppInfoFromPicsOnce — the once-per-process PICS refresh guard previously duplicated inside both loader functions. • Trimmed stale verifier-saga comments from the nullable menu-callback params; the full story lives once, on StoreGameDetailParams where the split it explains actually is. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../main/app/shell/LibraryGameLaunchScreen.kt | 5 +- .../main/app/shell/StoreGameDetailScreen.kt | 2 +- app/src/main/app/shell/UnifiedActivity.kt | 106 ++++++++---------- 3 files changed, 48 insertions(+), 65 deletions(-) diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 357ac4b36..842c017ca 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -134,8 +134,7 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, - // Nullable on purpose: null hides the menu item (mirrors StoreGameDetailScreen, - // whose signature size tripped ART's bytecode verifier with separate flags). + // Nullable on purpose: null hides the menu item. onLaunchOptions: (() -> Unit)? = null, onBetaBranches: (() -> Unit)? = null, playEnabled: Boolean = true, @@ -807,7 +806,7 @@ private fun SourceTag( onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - // null hides the menu item (see LibraryGameLaunchScreen's signature note). + // Nullable on purpose: null hides the menu item. onLaunchOptions: (() -> Unit)? = null, onBetaBranches: (() -> Unit)? = null, ) { diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 89bbe081d..36031aaec 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -944,7 +944,7 @@ private fun StoreSourceTag( onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - // null hides the menu item (see StoreGameDetailScreen's signature note). + // Nullable on purpose: null hides the menu item. onLaunchOptions: (() -> Unit)? = null, onBetaBranches: (() -> Unit)? = null, ) { diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 5039bdedf..959aacbba 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -8546,8 +8546,8 @@ class UnifiedActivity : } // Apps whose appinfo was already re-fetched this process (see - // loadSteamLaunchOptionsRefreshing / loadSteamBetaBranchesRefreshing) — - // avoids a PICS round-trip on every game-detail open. + // refreshAppInfoFromPicsOnce) — avoids a PICS round-trip on every + // game-detail open. private val appinfoRefreshedApps = java.util.Collections.synchronizedSet(mutableSetOf()) /** @@ -8584,6 +8584,19 @@ class UnifiedActivity : 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 @@ -8595,14 +8608,9 @@ class UnifiedActivity : ) { val (options, selected) = loadSteamLaunchOptions(appId) apply(options, selected) - if (appinfoRefreshedApps.add(appId)) { - if (SteamService.refreshAppInfoFromPics(appId)) { - val (fresh, freshSelected) = loadSteamLaunchOptions(appId) - apply(fresh, freshSelected) - } else { - // Offline or fetch failed — allow a retry on the next open. - appinfoRefreshedApps.remove(appId) - } + if (refreshAppInfoFromPicsOnce(appId)) { + val (fresh, freshSelected) = loadSteamLaunchOptions(appId) + apply(fresh, freshSelected) } } @@ -8651,19 +8659,20 @@ class UnifiedActivity : 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 (appinfoRefreshedApps.add(appId)) { - if (SteamService.refreshAppInfoFromPics(appId)) { - val (fresh, freshSelected) = loadSteamBetaBranches(appId) - apply(fresh, freshSelected) - } else { - appinfoRefreshedApps.remove(appId) - } + if (refreshAppInfoFromPicsOnce(appId)) { + val (fresh, freshSelected) = loadSteamBetaBranches(appId) + apply(fresh, freshSelected) } } @@ -8749,39 +8758,25 @@ class UnifiedActivity : ) LaunchedEffect(app.id, downloadRecords) { - // An exception in this effect tears down the whole Activity — degrade - // to an empty load (logged) instead of crashing the store screen. val loadData = - runCatching { - withContext(Dispatchers.IO) { - // Size the same branch the download will actually fetch. - val branch = 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, branch = branch) - } - val installedDlcIds = - SteamService.getInstalledDlcDepotsOf(app.id) - .orEmpty() - .toSet() - SteamInstallLoadData( - dlcApps = selectableDlcApps, - dlcSizes = perDlcSizes, - installedDlcIds = installedDlcIds, - baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id, branch = branch), - installed = SteamService.isAppInstalled(app.id), - ) - } - }.getOrElse { e -> - if (e is kotlinx.coroutines.CancellationException) throw e - Log.e("UnifiedActivity", "Steam install data load failed for appId=${app.id}", e) + withContext(Dispatchers.IO) { + // Size the same branch the download will actually fetch. + val branch = 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, branch = branch) + } + val installedDlcIds = + SteamService.getInstalledDlcDepotsOf(app.id) + .orEmpty() + .toSet() SteamInstallLoadData( - dlcApps = emptyList(), - dlcSizes = emptyMap(), - installedDlcIds = emptySet(), - baseManifestSizes = SteamService.ManifestSizes(), - installed = runCatching { withContext(Dispatchers.IO) { SteamService.isAppInstalled(app.id) } }.getOrDefault(false), + dlcApps = selectableDlcApps, + dlcSizes = perDlcSizes, + installedDlcIds = installedDlcIds, + baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id, branch = branch), + installed = SteamService.isAppInstalled(app.id), ) } dlcApps = loadData.dlcApps @@ -8794,16 +8789,11 @@ class UnifiedActivity : } LaunchedEffect(app.id, selectedDlcIds.toList()) { - runCatching { + selectedManifestSizes = withContext(Dispatchers.IO) { val branch = SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList(), branch = branch) } - }.onSuccess { selectedManifestSizes = it } - .onFailure { e -> - if (e is kotlinx.coroutines.CancellationException) throw e - Log.e("UnifiedActivity", "Steam DLC size load failed for appId=${app.id}", e) - } } LaunchedEffect(app.id, installed) { @@ -8980,13 +8970,7 @@ class UnifiedActivity : val installableDlcIds = dlcItems .filter { !it.isInstalled && it.id in selectedDlcIds } .map { it.id } - // An exception here is an app crash (plain launch, no - // handler) — surface it as a failed start instead. - runCatching { - SteamService.downloadApp(app.id, installableDlcIds, false, customPath) - }.onFailure { e -> - Log.w("UnifiedActivity", "Steam download failed to start for appId=${app.id}", e) - } + SteamService.downloadApp(app.id, installableDlcIds, false, customPath) withContext(Dispatchers.Main) { onDismissRequest() } } } From 95707c07f40be685ea7bbed977d5ef2dbf9b2769 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 13:29:37 +0000 Subject: [PATCH 18/21] =?UTF-8?q?feat(steam):=20pick=20beta=20branch=20bef?= =?UTF-8?q?ore=20downloading=20=E2=80=94=20split=20Download=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-install, the store screen's Download pill now has a small segment "sliced off" its left edge (AltRoute icon, 48dp — min touch target, mirrored 14dp/4dp corners with the house 8dp gap) that opens the existing beta-branch picker. The pick is STAGED in memory only (pendingBetaBranch); size cards retarget to the staged branch immediately, and nothing persists or downloads until the Download button commits it. • StoreCtaButton gains defaulted modifier/shape params; the glass brushes are extracted into storeCtaGlassBrushes shared with the new StoreCtaCutoutButton. Zero new StoreGameDetailScreen params — the existing nullable onBetaBranches drives the cutout pre-install and the STEAM-menu item post-install, so Epic/GOG (null) never show it. • GameManagerDialog loads beta branches for both install states (after the install state resolves, so the cutout can't flash on installed games); launch options stay install-gated. • BetaBranchesDialog is now a dumb shell with a single onSelect — the installed hosts (GameManager + library) persist and start the update check exactly as before; the pre-install host just stages and closes. • Download commit: persists a custom install path BEFORE the branch write (setSelectedBetaBranch creates the shortcut, which snapshots game_install_path and is never rewritten — committing in the other order would freeze the default path and break launch from custom dirs), then downloads; pause/resume re-resolves the branch from the now-persisted shortcut. If the shortcut can't be created yet (setup incomplete) the install proceeds on public with the existing beta-branch-failed toast. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../main/app/shell/StoreGameDetailScreen.kt | 230 +++++++++++++----- app/src/main/app/shell/UnifiedActivity.kt | 126 +++++++--- 2 files changed, 260 insertions(+), 96 deletions(-) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 36031aaec..ddee3d272 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -658,14 +658,55 @@ private fun StoreGameDetailContent(p: StoreGameDetailParams) { } 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. + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(actionIconSpacing), + ) { + StoreCtaCutoutButton( + height = ctaHeight, + icon = Icons.Outlined.AltRoute, + contentDescription = stringResource(R.string.store_game_beta_branch), + enabled = !isLoading, + shape = + RoundedCornerShape( + topStart = 14.dp, + bottomStart = 14.dp, + topEnd = 4.dp, + bottomEnd = 4.dp, + ), + onClick = onBetaBranches, + modifier = Modifier.width(actionIconSize), + ) + 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, + ), + ) + } + } else { + StoreCtaButton( + height = ctaHeight, + icon = Icons.Outlined.Download, + label = stringResource(R.string.common_ui_download), + enabled = !isLoading && isDownloadActionEnabled, + loading = isLoading, + onClick = onInstall, + ) + } } } } @@ -1193,6 +1234,67 @@ 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, +): 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), + ), + ) + } else { + Brush.horizontalGradient( + colors = + listOf( + Color(0xFF3A3A4A).copy(alpha = 0.35f), + Color(0xFF2A2A36).copy(alpha = 0.35f), + ), + ) + }, + 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, @@ -1201,6 +1303,8 @@ private fun StoreCtaButton( enabled: Boolean, loading: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier.fillMaxWidth(), + shape: RoundedCornerShape = RoundedCornerShape(14.dp), ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -1214,64 +1318,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) 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, @@ -1309,6 +1367,60 @@ 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, +) { + 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) + 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 959aacbba..5e147a3ab 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -5547,13 +5547,24 @@ class UnifiedActivity : if (showBetaBranchesDialog) { BetaBranchesDialog( - appId = app.id, gameTitle = app.name, branches = betaBranches, selectedBranch = selectedBetaBranch, - onSelectionSaved = { selectedBetaBranch = it }, + 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 }, - onStartUpdate = { startUpdateCheck(app.id, app.name) }, ) } } @@ -8728,6 +8739,8 @@ class UnifiedActivity : 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( @@ -8757,11 +8770,14 @@ 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. - val branch = SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } + // 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 -> @@ -8788,28 +8804,34 @@ class UnifiedActivity : isLoading = false } - LaunchedEffect(app.id, selectedDlcIds.toList()) { + LaunchedEffect(app.id, selectedDlcIds.toList(), pendingBetaBranch) { selectedManifestSizes = withContext(Dispatchers.IO) { - val branch = SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } + 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) { 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 + 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 @@ -8970,6 +8992,33 @@ class UnifiedActivity : val installableDlcIds = dlcItems .filter { !it.isInstalled && it.id in selectedDlcIds } .map { it.id } + // Commit the staged branch pick before the download so + // downloadApp (and any later resume) resolves it from the + // shortcut. Custom path first: the shortcut snapshots + // game_install_path at creation and is never rewritten. + val pendingBranch = pendingBetaBranch + if (installed != true && pendingBranch != null) { + customPath?.let { SteamService.setCustomInstallPath(app.id, it) } + val branchName = + if (pendingBranch.name.equals("public", ignoreCase = true)) { + "" + } else { + pendingBranch.name + } + val saved = + SteamService.setSelectedBetaBranch(applicationContext, app.id, branchName) + if (!saved) { + // Setup incomplete (no usable container yet) — install + // proceeds on the public branch rather than blocking. + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.store_game_beta_branch_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } SteamService.downloadApp(app.id, installableDlcIds, false, customPath) withContext(Dispatchers.Main) { onDismissRequest() } } @@ -9104,13 +9153,30 @@ class UnifiedActivity : if (showBetaBranchesDialog) { BetaBranchesDialog( - appId = app.id, gameTitle = app.name, branches = betaBranches, - selectedBranch = selectedBetaBranch, - onSelectionSaved = { selectedBetaBranch = it }, + selectedBranch = if (installed == true) selectedBetaBranch else 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 }, - onStartUpdate = { startUpdateCheck(app.id, app.name) }, ) } } @@ -9152,19 +9218,18 @@ class UnifiedActivity : /** * Hosts the Workshop-styled beta-branch picker window over a game detail - * dialog. Selecting an unlocked row persists it and triggers [onStartUpdate]. + * 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( - appId: Int, gameTitle: String, branches: List, selectedBranch: StoreBetaBranchItem?, - onSelectionSaved: (StoreBetaBranchItem) -> Unit, + onSelect: (StoreBetaBranchItem) -> Unit, onDismissRequest: () -> Unit, - onStartUpdate: () -> Unit, ) { - val scope = rememberCoroutineScope() Dialog( onDismissRequest = onDismissRequest, properties = @@ -9177,20 +9242,7 @@ class UnifiedActivity : gameTitle = gameTitle, branches = branches, selectedBranch = selectedBranch, - onSelect = { item -> - persistSteamBetaBranchSelection( - appId = appId, - item = item, - scope = scope, - onSaved = { saved -> - onSelectionSaved(saved) - // Close the picker so the update-check flow it triggers - // is what the user sees next, not a stale window. - onDismissRequest() - }, - startUpdate = onStartUpdate, - ) - }, + onSelect = onSelect, onClose = onDismissRequest, ) } From 935cc93ec0413aa27e793f780c44ff90d2ba6dff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 18:09:58 +0000 Subject: [PATCH 19/21] fix(steam): recover beta-branch selection lost to an app reinstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reinstalling WinNative wipes app-private shortcuts (where the selectedBranch extra lives) while game files on external storage survive — so a reinstalled app showed a beta install as "public", and worse, a tapped update check would diff the beta files against public manifests and silently downgrade the game. Steam survives this because it persists the beta key WITH the install (appmanifest ACF, UserConfig/betakey). Our durable analogs live in the surviving game dir, used in order of trust by the new recoverSelectedBetaName(appId): 1. .DepotDownloader/depot.config — the installed manifest gids matched exactly against each beta's PICS manifests (evidence of what the files actually are; requires all overlapping depots to match and at least one gid to differ from public, single candidate only); 2. steam_settings/configs.app.ini branch_name — the selection recorded at the game's last launch (checked at the game-dir root and next to the exe, the two settings-dir locations the launch path writes). A successful inference heals the shortcut via setSelectedBetaBranch, so every downstream consumer is consistent again. Wired at the three chokepoints: the branch-picker loader (UI), checkForAppUpdate / isUpdatePending (branch param now nullable, null = recover-and-resolve) and prepareLibSteamClientForLaunch (game launch). Known limitation: a never-launched install whose beta has since published a new build matches neither source and stays public until re-picked. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- app/src/main/app/shell/UnifiedActivity.kt | 4 +- .../stores/steam/service/SteamService.kt | 123 +++++++++++++++++- 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 5e147a3ab..ae66126cb 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -8662,7 +8662,9 @@ class UnifiedActivity : ?.map { b -> StoreBetaBranchItem(b.name, b.buildId, b.timeUpdated, b.pwdRequired) } ?.sortedWith(compareBy({ !it.name.equals("public", ignoreCase = true) }, { it.name })) .orEmpty() - val selectedName = SteamService.resolveSelectedBetaName(appId).ifBlank { "public" } + // 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. diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 756e41240..3887e6854 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -5497,7 +5497,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 -> @@ -5590,6 +5590,118 @@ class SteamService : Service() { }.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. + */ + suspend fun recoverSelectedBetaName(appId: Int): String { + val persisted = resolveSelectedBetaName(appId) + if (persisted.isNotEmpty()) return persisted + 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) + ?.takeIf { name -> betaNames.any { 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 = + getWindowsLaunchInfos(app.id) + .firstOrNull() + ?.executable + ?.replace('\\', '/') + ?.substringBeforeLast('/', "") + .orEmpty() + val candidates = + buildList { + add(File(appDirPath, "steam_settings/configs.app.ini")) + if (exeDir.isNotEmpty()) { + add(File(appDirPath, "$exeDir/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 @@ -7448,14 +7560,19 @@ class SteamService : Service() { suspend fun isUpdatePending( appId: Int, - branch: String = resolveSelectedBetaName(appId).ifBlank { "public" }, + branch: String? = null, ): Boolean = checkForAppUpdate(appId, branch).hasUpdate suspend fun checkForAppUpdate( appId: Int, - branch: String = resolveSelectedBetaName(appId).ifBlank { "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 " + From f78e0fc969a4f390a382f93975fb30d5dd5e5352 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 18:40:25 +0000 Subject: [PATCH 20/21] review(steam): multi-angle review fixes for pre-download selector + recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a seven-angle review of the two newest commits: • Branch-name casing: an ini-recovered name is now canonicalized to the PICS branches-map key before healing — downstream buildId lookups (app.branches[name]) are exact-key and would silently miss an ini-cased variant. • Recovery is memoized per process (betaRecoveryAttemptedApps): it can only succeed right after a WinNative reinstall, so permanently-public games no longer rerun the full inference (DB read + depot.config parse + ini probes) on every dialog open, update check and launch. • readBranchNameFromSettingsIni derives the exe dir from the SteamApp already in hand instead of getWindowsLaunchInfos' redundant blocking DB fetch of the same row. • pendingBetaBranch is cleared when a game resolves to installed — a staged pre-install pick was either committed by Download or abandoned, and could otherwise leak into a later session of the dialog. The host's selectedBranch expression simplifies to pending ?: persisted on that invariant. • The branch cutout now shares the Download button's enabled gate so users can't stage picks an actively-blocked download won't honor. • The 20-line commit block in onInstall is extracted to commitPendingBetaBranch, mirroring the persist helper next to it. Refuted during verification, for the record: cutout-without-betas (the host gates onBetaBranches on branches.size >= 2), cross-game state leaks (remember(app.id) keying), setSelectedBetaBranch false-success (it returns false on a null shortcut), customPath double-application (idempotent by construction), and the lost explicit-public API (callers can pass requestedBranch = "public"). https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../main/app/shell/StoreGameDetailScreen.kt | 4 +- app/src/main/app/shell/UnifiedActivity.kt | 62 ++++++++++--------- .../stores/steam/service/SteamService.kt | 26 +++++--- 3 files changed, 54 insertions(+), 38 deletions(-) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index ddee3d272..6d1d6167b 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -669,7 +669,9 @@ private fun StoreGameDetailContent(p: StoreGameDetailParams) { height = ctaHeight, icon = Icons.Outlined.AltRoute, contentDescription = stringResource(R.string.store_game_beta_branch), - enabled = !isLoading, + // Same gate as Download: don't stage picks the + // blocked download can't honor. + enabled = !isLoading && isDownloadActionEnabled, shape = RoundedCornerShape( topStart = 14.dp, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index ae66126cb..f011531c8 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -8689,6 +8689,31 @@ class UnifiedActivity : } } + /** + * 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, @@ -8820,7 +8845,11 @@ class UnifiedActivity : // 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) { + 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 } @@ -8994,32 +9023,8 @@ class UnifiedActivity : val installableDlcIds = dlcItems .filter { !it.isInstalled && it.id in selectedDlcIds } .map { it.id } - // Commit the staged branch pick before the download so - // downloadApp (and any later resume) resolves it from the - // shortcut. Custom path first: the shortcut snapshots - // game_install_path at creation and is never rewritten. - val pendingBranch = pendingBetaBranch - if (installed != true && pendingBranch != null) { - customPath?.let { SteamService.setCustomInstallPath(app.id, it) } - val branchName = - if (pendingBranch.name.equals("public", ignoreCase = true)) { - "" - } else { - pendingBranch.name - } - val saved = - SteamService.setSelectedBetaBranch(applicationContext, app.id, branchName) - if (!saved) { - // Setup incomplete (no usable container yet) — install - // proceeds on the public branch rather than blocking. - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.store_game_beta_branch_failed), - android.widget.Toast.LENGTH_SHORT, - ) - } - } + if (installed != true) { + pendingBetaBranch?.let { commitPendingBetaBranch(app.id, it, customPath) } } SteamService.downloadApp(app.id, installableDlcIds, false, customPath) withContext(Dispatchers.Main) { onDismissRequest() } @@ -9157,7 +9162,8 @@ class UnifiedActivity : BetaBranchesDialog( gameTitle = app.name, branches = betaBranches, - selectedBranch = if (installed == true) selectedBetaBranch else pendingBetaBranch ?: selectedBetaBranch, + // pendingBetaBranch is null for installed games (cleared on install). + selectedBranch = pendingBetaBranch ?: selectedBetaBranch, onSelect = { item -> if (installed == true) { persistSteamBetaBranchSelection( diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 3887e6854..b850d3d71 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -5606,9 +5606,17 @@ class SteamService : Service() { * 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 "" @@ -5620,7 +5628,9 @@ class SteamService : Service() { val inferred = inferBranchFromInstalledManifests(app, betaNames, appDirPath) ?: readBranchNameFromSettingsIni(app, appDirPath) - ?.takeIf { name -> betaNames.any { it.equals(name, ignoreCase = true) } } + // 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") @@ -5676,19 +5686,17 @@ class SteamService : Service() { appDirPath: String, ): String? { val exeDir = - getWindowsLaunchInfos(app.id) - .firstOrNull() + app.config.launch + .firstOrNull { it.executable.endsWith(".exe") } ?.executable ?.replace('\\', '/') ?.substringBeforeLast('/', "") .orEmpty() val candidates = - buildList { - add(File(appDirPath, "steam_settings/configs.app.ini")) - if (exeDir.isNotEmpty()) { - add(File(appDirPath, "$exeDir/steam_settings/configs.app.ini")) - } - } + 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 = From c839a4cbe988b73703c3eed20b8442fd1a77a0dc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 00:03:44 +0000 Subject: [PATCH 21/21] ui(steam): one continuous gradient across the split Download pill The beta-branch cutout and the Download segment each painted the full teal->purple gradient, so it repeated. Each segment now paints only its slice of a single gradient spanning the whole row, matching the look of the undivided Download button. https://claude.ai/code/session_013E3pTpGg4C6ropUPA9QyNB --- .../main/app/shell/StoreGameDetailScreen.kt | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 6d1d6167b..8836970da 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -661,8 +661,19 @@ private fun StoreGameDetailContent(p: StoreGameDetailParams) { 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(), + modifier = + Modifier + .fillMaxWidth() + .onSizeChanged { splitCtaWidthPx = it.width }, horizontalArrangement = Arrangement.spacedBy(actionIconSpacing), ) { StoreCtaCutoutButton( @@ -681,6 +692,9 @@ private fun StoreGameDetailContent(p: StoreGameDetailParams) { ), onClick = onBetaBranches, modifier = Modifier.width(actionIconSize), + fillEndX = + splitCtaWidthPx.takeIf { it > 0 }?.toFloat() + ?: Float.POSITIVE_INFINITY, ) StoreCtaButton( height = ctaHeight, @@ -697,6 +711,7 @@ private fun StoreGameDetailContent(p: StoreGameDetailParams) { topEnd = 14.dp, bottomEnd = 14.dp, ), + fillStartX = -splitCtaLeadPx, ) } } else { @@ -1246,6 +1261,8 @@ private class StoreCtaGlass( private fun storeCtaGlassBrushes( enabled: Boolean, flare: Float, + fillStartX: Float = 0f, + fillEndX: Float = Float.POSITIVE_INFINITY, ): StoreCtaGlass = StoreCtaGlass( fill = @@ -1257,6 +1274,8 @@ private fun storeCtaGlassBrushes( StoreAccent.copy(alpha = 0.38f), Color(0xFF7B2FF7).copy(alpha = 0.38f), ), + startX = fillStartX, + endX = fillEndX, ) } else { Brush.horizontalGradient( @@ -1265,6 +1284,8 @@ private fun storeCtaGlassBrushes( Color(0xFF3A3A4A).copy(alpha = 0.35f), Color(0xFF2A2A36).copy(alpha = 0.35f), ), + startX = fillStartX, + endX = fillEndX, ) }, sheen = @@ -1307,6 +1328,8 @@ private fun StoreCtaButton( 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() @@ -1320,7 +1343,7 @@ private fun StoreCtaButton( animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f), label = "storeCtaFlare", ) - val glass = storeCtaGlassBrushes(enabled, flare) + val glass = storeCtaGlassBrushes(enabled, flare, fillStartX, fillEndX) Box( modifier = modifier @@ -1382,6 +1405,8 @@ private fun StoreCtaCutoutButton( shape: RoundedCornerShape, onClick: () -> Unit, modifier: Modifier = Modifier, + fillStartX: Float = 0f, + fillEndX: Float = Float.POSITIVE_INFINITY, ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -1395,7 +1420,7 @@ private fun StoreCtaCutoutButton( animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f), label = "storeCtaCutoutFlare", ) - val glass = storeCtaGlassBrushes(enabled, flare) + val glass = storeCtaGlassBrushes(enabled, flare, fillStartX, fillEndX) Box( modifier = modifier