Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/src/main/app/shell/UnifiedActivityHub.kt
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ internal fun UnifiedActivity.UnifiedHub() {
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)
launchSteamGame(context, ContainerManager(context), app, f.joinArgs)
} else {
android.widget.Toast.makeText(
context,
Expand Down
124 changes: 124 additions & 0 deletions app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ pub struct FriendPersonaSnapshot {
pub rich_presence: Vec<(String, String)>,
pub game_name: String,
pub gameid: u64,
pub game_lobby_id: u64,
pub game_server_ip: u32,
pub game_server_port: u32,
}

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
Expand Down Expand Up @@ -1437,6 +1440,15 @@ impl CMClientCore {
if !friend.rich_presence.is_empty() {
existing.rich_presence = friend.rich_presence;
}
if friend.game_lobby_id.is_some() {
existing.game_lobby_id = friend.game_lobby_id;
}
if friend.game_server_ip.is_some() {
existing.game_server_ip = friend.game_server_ip;
}
if friend.game_server_port.is_some() {
existing.game_server_port = friend.game_server_port;
}
}
None => *self_persona = Some(friend),
}
Expand All @@ -1449,6 +1461,14 @@ impl CMClientCore {
if friend.has_persona_state {
slot.persona_state = friend.persona_state;
}
// Rich presence and lobby id have no "cleared" wire form, so without this a
// friend who switches games keeps the old game's join data.
if friend.has_game && friend.game_played_app_id != slot.game_played_app_id {
slot.rich_presence.clear();
slot.game_lobby_id = 0;
slot.game_server_ip = 0;
slot.game_server_port = 0;
}
if friend.has_game {
slot.game_played_app_id = friend.game_played_app_id;
}
Expand All @@ -1464,6 +1484,15 @@ impl CMClientCore {
if !friend.rich_presence.is_empty() {
slot.rich_presence = friend.rich_presence;
}
if let Some(lobby) = friend.game_lobby_id {
slot.game_lobby_id = lobby;
}
if let Some(ip) = friend.game_server_ip {
slot.game_server_ip = ip;
}
if let Some(port) = friend.game_server_port {
slot.game_server_port = port;
}
}
}
InboundAction::PersonaState(count)
Expand Down Expand Up @@ -1953,6 +1982,101 @@ mod tests {
assert_eq!(persona.persona_state, 2);
}

#[test]
fn persona_lobby_survives_partial_update_and_clears_on_explicit_zero() {
let core = logged_on_core();
let push = |body: Vec<u8>| {
let mut wrapped = Vec::new();
Writer::new(&mut wrapped).submessage_field(2, &body);
core.route_inbound(
EMsg::CLIENT_PERSONA_STATE,
&CMsgProtoBufHeader::default(),
&wrapped,
);
};

let mut joined = Vec::new();
{
let mut w = Writer::new(&mut joined);
w.fixed64_field(1, 444);
w.string_field(15, "Grace");
w.uint32_field(3, 3527290);
w.fixed64_field(73, 109775241234567890);
}
push(joined);
assert_eq!(
core.friend_personas().pop().unwrap().game_lobby_id,
109775241234567890
);

let mut partial = Vec::new();
{
let mut w = Writer::new(&mut partial);
w.fixed64_field(1, 444);
w.uint32_field(2, 1);
}
push(partial);
assert_eq!(
core.friend_personas().pop().unwrap().game_lobby_id,
109775241234567890,
"an absent lobby field must not clear the stored lobby"
);

let mut left = Vec::new();
{
let mut w = Writer::new(&mut left);
w.fixed64_field(1, 444);
w.tag(73, WireType::Fixed64);
w.raw_bytes(&0u64.to_le_bytes());
}
push(left);
assert_eq!(core.friend_personas().pop().unwrap().game_lobby_id, 0);
}

#[test]
fn switching_games_drops_the_previous_games_join_data() {
let core = logged_on_core();
let push = |body: Vec<u8>| {
let mut wrapped = Vec::new();
Writer::new(&mut wrapped).submessage_field(2, &body);
core.route_inbound(
EMsg::CLIENT_PERSONA_STATE,
&CMsgProtoBufHeader::default(),
&wrapped,
);
};

let mut kv = Vec::new();
{
let mut w = Writer::new(&mut kv);
w.string_field(1, "connect");
w.string_field(2, "+connect 1.2.3.4:27015");
}
let mut in_peak = Vec::new();
{
let mut w = Writer::new(&mut in_peak);
w.fixed64_field(1, 444);
w.string_field(15, "Grace");
w.uint32_field(3, 3527290);
w.submessage_field(71, &kv);
w.fixed64_field(73, 109775241234567890);
}
push(in_peak);

let mut switched = Vec::new();
{
let mut w = Writer::new(&mut switched);
w.fixed64_field(1, 444);
w.uint32_field(3, 440);
}
push(switched);

let persona = core.friend_personas().pop().unwrap();
assert_eq!(persona.game_played_app_id, 440);
assert!(persona.rich_presence.is_empty());
assert_eq!(persona.game_lobby_id, 0);
}

#[test]
fn account_info_push_parses_bridge_fields() {
let core = CMClientCore::default();
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/cpp/wn-steam-client/rust/src/jni.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2142,6 +2142,10 @@ pub extern "system" fn Java_com_winlator_cmod_feature_stores_steam_wnsteam_WnSte
.find(|(k, _)| k == "connect")
.map(|(_, v)| v.as_str())
.unwrap_or(""),
"lobbyId": persona.game_lobby_id as i64,
// as i64 so JSONObject.optLong keeps the full u32 range; optInt would truncate.
"serverIp": persona.game_server_ip as i64,
"serverPort": persona.game_server_port,
}))
.collect::<Vec<_>>())
.to_string();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ pub struct PersonaStateFriend {
pub game_name: String,
pub gameid: u64,
pub rich_presence: Vec<(String, String)>,
// None = the field was absent (keep what we have); Some(0) = the server cleared it.
pub game_lobby_id: Option<u64>,
pub game_server_ip: Option<u32>,
pub game_server_port: Option<u32>,
pub has_persona_state: bool,
pub has_game: bool,
}
Expand All @@ -50,13 +54,16 @@ impl PersonaStateFriend {
msg.game_played_app_id = reader.u32()?;
msg.has_game = true;
}
(4, WireType::Varint) => msg.game_server_ip = Some(reader.u32()?),
(5, WireType::Varint) => msg.game_server_port = Some(reader.u32()?),
(15, WireType::LengthDelimited) => msg.player_name = reader.string()?,
(25, WireType::LengthDelimited) => msg
.rich_presence
.push(parse_kv_submessage(reader.bytes()?)?),
(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()?,
(71, WireType::LengthDelimited) => msg
.rich_presence
.push(parse_kv_submessage(reader.bytes()?)?),
(73, WireType::Fixed64) => msg.game_lobby_id = Some(reader.fixed64()?),
_ => {
if !reader.skip(tag.wire_type) {
return None;
Expand Down Expand Up @@ -140,7 +147,7 @@ mod tests {
w.uint32_field(2, 1);
w.uint32_field(3, 440);
w.string_field(15, "Ada");
w.submessage_field(25, &kv);
w.submessage_field(71, &kv);
w.bytes_field(31, &[1, 2, 3]);
w.string_field(55, "Team Fortress 2");
w.fixed64_field(56, 440);
Expand All @@ -161,14 +168,15 @@ mod tests {
}

#[test]
fn stateful_push_with_field25_as_fixed64_still_parses() {
// Live persona pushes carry field 25 as a fixed64, not the rich-presence submessage.
fn stateful_push_with_steamid_source_is_skipped_not_read_as_rich_presence() {
// Field 25 is steamid_source (a fixed64), not rich presence — reading it as rich
// presence is what kept every friend's connect string empty.
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.fixed64_field(25, 90071996842377216);
w.string_field(15, "Online Friend");
}
let mut body = Vec::new();
Expand All @@ -180,5 +188,67 @@ mod tests {
assert_eq!(friend.persona_state, 1);
assert!(friend.has_persona_state);
assert_eq!(friend.player_name, "Online Friend");
assert!(friend.rich_presence.is_empty());
}

#[test]
fn parses_lobby_and_game_server_fields() {
let mut friend = Vec::new();
{
let mut w = Writer::new(&mut friend);
w.fixed64_field(1, 5);
w.uint32_field(3, 3527290);
w.uint32_field(4, 0x681E100F);
w.uint32_field(5, 27015);
w.fixed64_field(73, 109775241234567890);
}
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.game_played_app_id, 3527290);
assert_eq!(friend.game_server_ip, Some(0x681E100F));
assert_eq!(friend.game_server_port, Some(27015));
assert_eq!(friend.game_lobby_id, Some(109775241234567890));
}

#[test]
fn explicit_zero_lobby_id_is_present_not_absent() {
// Leaving a lobby arrives as an explicit 0, which must clear the stored id. The Writer
// helpers omit zero-valued fields, so emit the tag and payload directly.
let mut friend = Vec::new();
{
let mut w = Writer::new(&mut friend);
w.fixed64_field(1, 5);
w.tag(73, WireType::Fixed64);
w.raw_bytes(&0u64.to_le_bytes());
w.uint32_field_force(4, 0);
}
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.game_lobby_id, Some(0));
assert_eq!(friend.game_server_ip, Some(0));
}

#[test]
fn absent_lobby_id_stays_none() {
let mut friend = Vec::new();
{
let mut w = Writer::new(&mut friend);
w.fixed64_field(1, 5);
w.uint32_field(2, 1);
}
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.game_lobby_id, None);
assert_eq!(friend.game_server_ip, None);
assert_eq!(friend.game_server_port, None);
}
}
18 changes: 17 additions & 1 deletion app/src/main/feature/stores/steam/data/SteamFriendEntry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,21 @@ data class SteamFriendEntry(
val gameName: String = "",
val avatarHash: String = "",
val connectString: String = "",
val lobbyId: Long = 0L,
val gameServerIp: Long = 0L,
val gameServerPort: Int = 0,
) {
// Mirrors Steam's own join precedence: connect command line, then lobby, then game server.
// connectString is friend-controlled and reaches a Windows command line, hence the filter.
val joinArgs: String = when {
connectString.isNotBlank() -> connectString.trim().filter { it >= ' ' && it != '"' }
lobbyId != 0L -> "+connect_lobby ${java.lang.Long.toUnsignedString(lobbyId)}"
gameServerIp != 0L && gameServerPort > 0 -> "+connect ${ipv4(gameServerIp)}:$gameServerPort"
else -> ""
}

val isJoinable: Boolean
get() = isPlayingGame && gameAppId > 0 && connectString.isNotBlank()
get() = isPlayingGame && gameAppId > 0 && joinArgs.isNotEmpty()

val isOnline: Boolean
get() = state.code() in 1..6
Expand All @@ -33,3 +45,7 @@ data class SteamFriendEntry(
get() = gameAppId.takeIf { it > 0 }
?.let { "https://cdn.cloudflare.steamstatic.com/steam/apps/$it/header.jpg" }
}

// Steam carries game_server_ip as a host-order uint32 — high byte is the first octet.
private fun ipv4(ip: Long): String =
"${(ip shr 24) and 0xFF}.${(ip shr 16) and 0xFF}.${(ip shr 8) and 0xFF}.${ip and 0xFF}"
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ private fun FriendActionButtons(
onPlayGame: (SteamFriendEntry) -> Unit,
) {
when {
friend.isJoinable -> {
friend.isJoinable && gameInstalled -> {
Spacer(Modifier.width(8.dp))
FriendActionButton(stringResource(R.string.steam_friends_join), Icons.Outlined.PlayArrow) { onJoinGame(friend) }
}
Expand Down
Loading