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
87 changes: 86 additions & 1 deletion app/src/main/java/app/gamenative/ui/model/MainViewModel.kt
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also the "parentProcess" which is worth considering if this is worth checking for too. I believe it comes from RunningAppInfo from javasteam

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, looked into parentProcess, it's null unfortunately. I don't know if this one is worth merging, but we should probably find a better way to track playtime rather than just checking the exe name.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, happy to also look at this. I've created a thread here: https://discord.com/channels/1378308569287622737/1499071031795519548

Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,20 @@ import app.gamenative.ui.enums.ConnectionState
import app.gamenative.ui.screen.PluviaScreen
import app.gamenative.utils.ContainerUtils
import app.gamenative.utils.IntentLaunchManager
import app.gamenative.ui.screen.xserver.CORE_WINE_PROCESSES
import app.gamenative.utils.SteamUtils
import app.gamenative.utils.UpdateInfo
import com.materialkolor.PaletteStyle
import com.winlator.winhandler.OnGetProcessInfoListener
import com.winlator.winhandler.ProcessInfo
import com.winlator.xserver.Window
import dagger.hilt.android.lifecycle.HiltViewModel
import `in`.dragonbra.javasteam.steam.handlers.steamapps.AppProcessInfo
import java.nio.file.Paths
import javax.inject.Inject
import kotlin.io.path.name
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
Expand All @@ -45,6 +49,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber

@HiltViewModel
Expand All @@ -55,6 +61,55 @@ class MainViewModel @Inject constructor(

companion object {
private const val KEY_CURRENT_SCREEN_ROUTE = "current_screen_route"
private const val PROCESS_LIST_TIMEOUT_MS = 2000L
}

private var hasSentPlayingNotification = false

private suspend fun isLaunchExeRunning(launchExeNames: List<String>): Boolean {
if (launchExeNames.isEmpty()) return false
val winHandler = PluviaApp.xServerView?.getxServer()?.winHandler ?: return false
val targets = launchExeNames.toSet()

return withContext(Dispatchers.IO) {
val previousListener = winHandler.getOnGetProcessInfoListener()
val snapshotDeferred = CompletableDeferred<List<ProcessInfo>>()
val lock = Any()
val currentList = mutableListOf<ProcessInfo>()
var expectedCount = -1

val listener = OnGetProcessInfoListener { index, count, processInfo ->
previousListener?.onGetProcessInfo(index, count, processInfo)
synchronized(lock) {
if (count == 0 && processInfo == null) {
if (!snapshotDeferred.isCompleted) snapshotDeferred.complete(emptyList())
return@synchronized
}
if (index == 0) {
currentList.clear()
expectedCount = count
}
if (processInfo != null) {
currentList.add(processInfo)
}
if (expectedCount >= 0 && currentList.size >= expectedCount && !snapshotDeferred.isCompleted) {
snapshotDeferred.complete(currentList.toList())
}
}
}

winHandler.setOnGetProcessInfoListener(listener)
try {
winHandler.listProcesses()
val snapshot = withTimeoutOrNull(PROCESS_LIST_TIMEOUT_MS) {
snapshotDeferred.await()
} ?: return@withContext false

snapshot.any { it.name.lowercase() in targets }
} finally {
winHandler.setOnGetProcessInfoListener(previousListener)
}
}
Comment on lines +69 to +112
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize ProcessInfo.name before matching it.

Line 108 compares raw process-list names against basename-only launch exes. In this codebase the same WinHandler process names are normalized elsewhere before comparison because they may include a path, quotes, or a .exe suffix. As written, this fallback can miss the launched process and never send the playing notification.

♻️ Suggested normalization
+    private fun normalizeProcessName(name: String): String {
+        val base = name.trim().trim('"').substringAfterLast('/').substringAfterLast('\\')
+        return base.lowercase().removeSuffix(".exe")
+    }
+
     private suspend fun isLaunchExeRunning(launchExeNames: List<String>): Boolean {
         if (launchExeNames.isEmpty()) return false
         val winHandler = PluviaApp.xServerView?.getxServer()?.winHandler ?: return false
-        val targets = launchExeNames.toSet()
+        val targets = launchExeNames.map(::normalizeProcessName).toSet()
...
-                snapshot.any { it.name.lowercase() in targets }
+                snapshot.any { normalizeProcessName(it.name) in targets }
             } finally {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/model/MainViewModel.kt` around lines 69 -
112, In isLaunchExeRunning, normalize each ProcessInfo.name the same way other
WinHandler checks do before comparing to launchExeNames: for example, strip
surrounding quotes, remove directory path to get the basename, trim a trailing
“.exe” (case-insensitive), then lowercase the result before testing membership
in targets; update the snapshot.any check to normalize it (e.g.,
normalizeProcessName(processInfo.name) ) and compare against targets so
path/quote/.exe variants match the basename-only launchExeNames.

}

sealed class MainUiEvent {
Expand Down Expand Up @@ -488,6 +543,7 @@ class MainViewModel @Inject constructor(

val gameId = ContainerUtils.extractGameIdFromContainerId(appId)
Timber.tag("Exit").i("Got game id: $gameId")
hasSentPlayingNotification = false
SteamService.notifyRunningProcesses()
handleExitCloudSync(context, appId, gameId)

Expand Down Expand Up @@ -614,7 +670,8 @@ class MainViewModel @Inject constructor(
gameExe == windowExe
}

if (launchConfig != null) {
if (launchConfig != null && !hasSentPlayingNotification) {
hasSentPlayingNotification = true
val steamProcessId = Process.myPid()
val processes = mutableListOf<AppProcessInfo>()
var currentWindow: Window = window
Expand Down Expand Up @@ -652,6 +709,34 @@ class MainViewModel @Inject constructor(
Timber.tag("MainViewModel").i("Skipping Steam process notification - real Steam will handle this")
}
}
} else if (!hasSentPlayingNotification) {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Race condition: hasSentPlayingNotification is checked here but only set to true after the suspend call to isLaunchExeRunning(). If two windows map concurrently, both coroutines can observe false, both await the process snapshot, and both send duplicate playing notifications. Set the flag (or use a mutex) before the suspend point to prevent this.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/model/MainViewModel.kt, line 712:

<comment>Race condition: `hasSentPlayingNotification` is checked here but only set to `true` after the suspend call to `isLaunchExeRunning()`. If two windows map concurrently, both coroutines can observe `false`, both await the process snapshot, and both send duplicate playing notifications. Set the flag (or use a mutex) before the suspend point to prevent this.</comment>

<file context>
@@ -652,6 +709,34 @@ class MainViewModel @Inject constructor(
                             Timber.tag("MainViewModel").i("Skipping Steam process notification - real Steam will handle this")
                         }
                     }
+                } else if (!hasSentPlayingNotification) {
+                    val windowBase = window.className.substringAfterLast('\\')
+                        .substringAfterLast('/').lowercase().removeSuffix(".exe")
</file context>
Fix with Cubic

val windowBase = window.className.substringAfterLast('\\')
.substringAfterLast('/').lowercase().removeSuffix(".exe")
if (windowBase in CORE_WINE_PROCESSES) return@let

val launchExeNames = SteamService.getWindowsLaunchInfos(gameId)
.map { Paths.get(it.executable.replace('\\', '/')).name.lowercase() }
if (isLaunchExeRunning(launchExeNames)) {
val shouldLaunchRealSteam = try {
val container = ContainerUtils.getContainer(context, appId)
container.isLaunchRealSteam()
} catch (e: Exception) {
false
}
if (!shouldLaunchRealSteam) {
hasSentPlayingNotification = true
val installedBranch = SteamService.getInstalledApp(gameId)?.branch ?: "public"
val processInfo = GameProcessInfo(
appId = gameId,
branch = installedBranch,
processes = listOf(AppProcessInfo(Process.myPid(), Process.myPid(), true)),
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Fallback runtime notification reports the Android PID instead of the game/Wine process PID, causing incorrect process tracking.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/model/MainViewModel.kt, line 732:

<comment>Fallback runtime notification reports the Android PID instead of the game/Wine process PID, causing incorrect process tracking.</comment>

<file context>
@@ -652,6 +709,34 @@ class MainViewModel @Inject constructor(
+                            val processInfo = GameProcessInfo(
+                                appId = gameId,
+                                branch = installedBranch,
+                                processes = listOf(AppProcessInfo(Process.myPid(), Process.myPid(), true)),
+                            )
+                            Timber.tag("MainViewModel").i(
</file context>
Fix with Cubic

)
Timber.tag("MainViewModel").i(
"Sending playing notification via process-list fallback for appId=%d", gameId,
)
SteamService.notifyRunningProcesses(processInfo)
Comment on lines +729 to +737
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the matched guest PID here, not Process.myPid().

SteamService.notifyRunningProcesses() uses this processId as the running-game identity. Reporting the Android host PID means every fallback notification in the same app process has the same PID, so titles that only hit this path still are not tracked per launched process. isLaunchExeRunning() needs to return the matched ProcessInfo, not just a Boolean.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/model/MainViewModel.kt` around lines 729
- 737, The fallback notification is using Process.myPid() instead of the matched
guest PID, causing all fallbacks to report the host PID; update MainViewModel
where GameProcessInfo and AppProcessInfo are constructed (currently using
Process.myPid()) to use the guest process id returned by isLaunchExeRunning()
(make isLaunchExeRunning() return the matched ProcessInfo rather than a
Boolean), then pass that matched ProcessInfo.pid into AppProcessInfo so
SteamService.notifyRunningProcesses() receives the correct guest/process
identity.

}
}
Comment on lines +712 to +739
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The fallback branch can still send duplicate playing notifications.

hasSentPlayingNotification is only flipped after the suspend call to isLaunchExeRunning(). If two windows map close together, both coroutines can enter this branch with false, both await the snapshot, and both call notifyRunningProcesses(). Guard the branch with a mutex/in-flight flag, or reserve the notification slot before awaiting.

🧰 Tools
🪛 detekt (1.23.8)

[warning] 723-723: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/model/MainViewModel.kt` around lines 712
- 739, The fallback path can send duplicate notifications because
hasSentPlayingNotification is only set after the suspend call
isLaunchExeRunning; modify MainViewModel to atomically reserve/send guard before
any suspension by either (a) introducing a mutex or a volatile/in-flight flag
checked-and-set before calling isLaunchExeRunning, or (b) set
hasSentPlayingNotification (or a new inFlightNotification flag) immediately
after confirming windowBase not in CORE_WINE_PROCESSES and before awaiting
isLaunchExeRunning; keep the subsequent logic (ContainerUtils.getContainer,
isLaunchRealSteam, SteamService.notifyRunningProcesses) unchanged but ensure the
flag is cleared or remains set appropriately so concurrent coroutines cannot
both call SteamService.notifyRunningProcesses for the same gameId.

}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ private data class XServerViewReleaseBinding(
val windowModificationListener: WindowManager.OnWindowModificationListener,
)

private val CORE_WINE_PROCESSES = setOf(
val CORE_WINE_PROCESSES = setOf(
"wineserver",
"services",
"start",
Expand Down
Loading