From 21f27f6bb639861e519ba02078e5ed3b0d066b09 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 27 Jun 2026 22:58:12 +0200 Subject: [PATCH 01/19] Refactor: Refactored several classes such as SessionKeepAliveService, NotificationHelper, and LogManager. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main changes: - Refactored and optimized the SessionKeepAliveService class with the following changes: - Converted into the master class for the app’s general foreground service. - The foreground service starts in this class whenever there is any other class that needs to prevent the app from being killed by the OS. - Changed the Foreground type from SpecialUse to DataSync and commented out the wakelock code. DataSync should be sufficient. - SteamService calls this class when it starts its own normal service. - XServerDisplayActivity calls this class when it starts. - All foreground-related notifications are updated with the relevant messages. - The game/container session now offers the option to close the container from the notification. - Previous code and code that proved not useful in the tests performed have been commented out. It will be removed once it’s confirmed that the current changes work and that this code is no longer required. - Refactored the NotificationHelper class to be independent of the stores and usable more generally and broadly. - The class now handles, in general, based on the parameters provided: - Create the notification channel. - Create the notification with the given parameters. - Update the notifications. - Generate a specific ID for each notification based on the package name and a string defined in each class that requires a notification. - Refactored the LogManager class: - Regular logs (Log.d, Log.w, Timber.d, Timber.e, etc.) can now be replaced by LogManager methods while preserving their functionality, and in addition to gaining access to them from the text files generated by the class without needing to use logcat. - Included a Watcher for when the container goes into pause due to backgrounding, to determine what caused the container to be closed. - To be more useful, it requires a special permission in the manifest that allows reading system logs. This grants access to logs such as ActivityManager logs, for example, which is responsible for killing background processes. - Included the ability to know why the app previously closed. - Included the ability to extract crash logs that recently affected the app. Other changes: - ProcessHelper: - Commented out the code that modifies each process's OOM score (need to verify its usefulness with adb) - Commented out the code that leaves some wine processes and others unpaused. Other forks don’t have issues with all processes paused. - Fixed an error with readAllBytes() from InputStream that crashes on Android versions below API 33. - Store services: - In Epic and GOG, moved the variables to the top of the class for better organization. - SteamService migrated the foreground service to SessionKeepAliveService and replaced it with a normal service. - SteamLogin classes: Fixed a crash related to the migration of the foreground service. - Configured Timber logs to work with debug apks (initialized in UnifiedActivity.kt) - Client.java and XConnectorEpoll.java: - Fixed a silent crash that was thought to be responsible for the container closing. It seems not to be the case. - AndroidManifest: Removed WakeLock permission and temporarily added READ_LOGS to obtain more details in logs related to background activity. - XServerDisplayActivity: The line that forced the container to render on top of the lock screen has been commented out. --- app/src/main/AndroidManifest.xml | 2 +- app/src/main/app/shell/UnifiedActivity.kt | 13 + .../stores/epic/service/EpicService.kt | 54 +- .../feature/stores/gog/service/GOGService.kt | 71 ++- .../stores/steam/SteamLoginActivity.kt | 5 +- .../stores/steam/service/SteamService.kt | 156 +++-- .../stores/steam/ui/SteamLoginViewModel.kt | 5 +- .../display/XServerDisplayActivity.java | 123 ++-- .../runtime/display/connector/Client.java | 15 +- .../display/connector/XConnectorEpoll.java | 1 + app/src/main/runtime/system/LogManager.kt | 399 +++++++++++- .../main/runtime/system/ProcessHelper.java | 20 +- .../system/SessionKeepAliveService.java | 603 ++++++++++++++---- .../main/shared/android/NotificationHelper.kt | 197 ++++-- 14 files changed, 1261 insertions(+), 403 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eecd5fb2d..dd48869c3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,7 +34,7 @@ - + diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 13e32d4e9..8ac3b612d 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -216,12 +216,14 @@ 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 com.winlator.cmod.runtime.system.LogManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber import javax.inject.Inject import kotlin.math.abs import kotlin.math.roundToInt @@ -996,6 +998,17 @@ class UnifiedActivity : override fun onCreate(savedInstanceState: Bundle?) { instance = this super.onCreate(savedInstanceState) + + // Initialize Timber in debug builds for logging. + // If 'BuildConfig' give error, ignore it. + // The file needed will be autogenerated when building the apk. + if (com.winlator.cmod.BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()); + } + + // Initialize the LogManager context in case a fallback is needed. + LogManager.init(this) + if (!SetupWizardActivity.isSetupComplete(this) || !ImageFs.find(this).isUpToDate) { startActivity( Intent(this, SetupWizardActivity::class.java) diff --git a/app/src/main/feature/stores/epic/service/EpicService.kt b/app/src/main/feature/stores/epic/service/EpicService.kt index 20b9b230e..aad8ad7d5 100644 --- a/app/src/main/feature/stores/epic/service/EpicService.kt +++ b/app/src/main/feature/stores/epic/service/EpicService.kt @@ -35,9 +35,35 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject +import android.content.pm.ServiceInfo +import android.os.Build +import com.winlator.cmod.shared.android.NotificationHelper.Companion.ACTION_EXIT + // Foreground service facade for Epic auth, library sync, downloads, and cloud saves. @AndroidEntryPoint class EpicService : Service() { + private lateinit var notificationHelper: NotificationHelper + var notificationID = 1 + + @Inject + lateinit var epicManager: EpicManager + + @Inject + lateinit var epicDownloadManager: EpicDownloadManager + + @Inject + lateinit var epicVerifyManager: EpicVerifyManager + + @Inject + lateinit var epicUpdateManager: EpicUpdateManager + + @Inject + lateinit var epicOverlayManager: EpicOverlayManager + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val activeDownloads = ConcurrentHashMap() + companion object { private var instance: EpicService? = null @@ -54,6 +80,8 @@ class EpicService : Service() { val isRunning: Boolean get() = instance != null + @Volatile private var appInForeground = true + fun start(context: Context) { Timber.tag("EPIC").d("Starting service...") if (isRunning) { @@ -96,7 +124,8 @@ class EpicService : Service() { service.stopForeground(Service.STOP_FOREGROUND_REMOVE) }.onFailure { Timber.w(it, "Failed to remove EpicService foreground state during shutdown") } runCatching { - service.notificationHelper.cancel() + if (service::notificationHelper.isInitialized) + service.notificationHelper.cancel(service.notificationID) }.onFailure { Timber.w(it, "Failed to cancel EpicService notification during shutdown") } service.stopSelf() } @@ -1126,27 +1155,6 @@ class EpicService : Service() { } } - private lateinit var notificationHelper: NotificationHelper - - @Inject - lateinit var epicManager: EpicManager - - @Inject - lateinit var epicDownloadManager: EpicDownloadManager - - @Inject - lateinit var epicVerifyManager: EpicVerifyManager - - @Inject - lateinit var epicUpdateManager: EpicUpdateManager - - @Inject - lateinit var epicOverlayManager: EpicOverlayManager - - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - private val activeDownloads = ConcurrentHashMap() - // Original download parameters per appId so resume can restore DLC selection, // language, and install path instead of falling back to defaults. data class DownloadParams( @@ -1418,7 +1426,7 @@ class EpicService : Service() { scope.cancel() stopForeground(STOP_FOREGROUND_REMOVE) - notificationHelper.cancel() + notificationHelper.cancel(notificationID) instance = null } diff --git a/app/src/main/feature/stores/gog/service/GOGService.kt b/app/src/main/feature/stores/gog/service/GOGService.kt index b903a73d5..168b97af8 100644 --- a/app/src/main/feature/stores/gog/service/GOGService.kt +++ b/app/src/main/feature/stores/gog/service/GOGService.kt @@ -34,9 +34,45 @@ import java.util.concurrent.CopyOnWriteArrayList import java.util.zip.ZipOutputStream import javax.inject.Inject +import android.content.pm.ServiceInfo +import android.os.Build +import com.winlator.cmod.shared.android.NotificationHelper.Companion.ACTION_EXIT + // Foreground service facade for GOG auth, library sync, downloads, and cloud saves. @AndroidEntryPoint class GOGService : Service() { + + private lateinit var notificationHelper: NotificationHelper + var notificationID = 1 + + @Inject + lateinit var gogManager: GOGManager + + @Inject + lateinit var gogDownloadManager: GOGDownloadManager + + @Inject + lateinit var gogVerifyManager: GOGVerifyManager + + @Inject + lateinit var gogUpdateManager: GOGUpdateManager + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val activeDownloads = ConcurrentHashMap() + + // Download parameters per gameId so resume can restore container language and + // install path instead of falling back to defaults. + data class DownloadParams( + val dlcGameIds: List, + val containerLanguage: String, + val installPath: String, + ) + + private val downloadParams = ConcurrentHashMap() + + private val onEndProcess: (AndroidEvent.EndProcess) -> Unit = { stop() } + companion object { private const val ACTION_SYNC_LIBRARY = "com.winlator.cmod.GOG_SYNC_LIBRARY" private const val ACTION_MANUAL_SYNC = "com.winlator.cmod.GOG_MANUAL_SYNC" @@ -93,7 +129,8 @@ class GOGService : Service() { service.stopForeground(Service.STOP_FOREGROUND_REMOVE) }.onFailure { Timber.w(it, "Failed to remove GOGService foreground state during shutdown") } runCatching { - service.notificationHelper.cancel() + if (service::notificationHelper.isInitialized) + service.notificationHelper.cancel(service.notificationID) }.onFailure { Timber.w(it, "Failed to cancel GOGService notification during shutdown") } service.stopSelf() } @@ -1440,36 +1477,6 @@ class GOGService : Service() { } } - private lateinit var notificationHelper: NotificationHelper - - @Inject - lateinit var gogManager: GOGManager - - @Inject - lateinit var gogDownloadManager: GOGDownloadManager - - @Inject - lateinit var gogVerifyManager: GOGVerifyManager - - @Inject - lateinit var gogUpdateManager: GOGUpdateManager - - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - private val activeDownloads = ConcurrentHashMap() - - // Download parameters per gameId so resume can restore container language and - // install path instead of falling back to defaults. - data class DownloadParams( - val dlcGameIds: List, - val containerLanguage: String, - val installPath: String, - ) - - private val downloadParams = ConcurrentHashMap() - - private val onEndProcess: (AndroidEvent.EndProcess) -> Unit = { stop() } - private val coordinatorDispatcher = object : DownloadCoordinator.Dispatcher { override fun startQueued(record: DownloadRecord) { @@ -1664,7 +1671,7 @@ class GOGService : Service() { scope.cancel() stopForeground(STOP_FOREGROUND_REMOVE) - notificationHelper.cancel() + notificationHelper.cancel(notificationID) instance = null } diff --git a/app/src/main/feature/stores/steam/SteamLoginActivity.kt b/app/src/main/feature/stores/steam/SteamLoginActivity.kt index d278c5766..bb662a669 100644 --- a/app/src/main/feature/stores/steam/SteamLoginActivity.kt +++ b/app/src/main/feature/stores/steam/SteamLoginActivity.kt @@ -48,6 +48,7 @@ import com.winlator.cmod.feature.stores.steam.enums.LoginScreen import com.winlator.cmod.feature.stores.steam.ui.SteamLoginViewModel import com.winlator.cmod.feature.stores.steam.ui.components.QrCodeImage import com.winlator.cmod.feature.stores.steam.ui.data.UserLoginState +import com.winlator.cmod.runtime.system.SessionKeepAliveService import com.winlator.cmod.shared.android.FixedFontScaleComponentActivity import com.winlator.cmod.shared.theme.WinNativeTheme import com.winlator.cmod.shared.ui.outlinedSwitchColors @@ -68,7 +69,9 @@ class SteamLoginActivity : FixedFontScaleComponentActivity() { super.onCreate(savedInstanceState) try { - startForegroundService(android.content.Intent(this, com.winlator.cmod.feature.stores.steam.service.SteamService::class.java)) +// startForegroundService(android.content.Intent(this, com.winlator.cmod.feature.stores.steam.service.SteamService::class.java)) + startService(android.content.Intent(this, com.winlator.cmod.feature.stores.steam.service.SteamService::class.java)) + SessionKeepAliveService.startComponent(this, SessionKeepAliveService.COMPONENT_STEAM, "Steam Login Service") } catch (e: Exception) { Timber.e(e, "Failed to start SteamService from SteamLoginActivity") } diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index d2f6cefd3..a1b929f8c 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -7,33 +7,41 @@ import android.util.Base64 import android.util.Log import android.widget.Toast import androidx.room.withTransaction +import com.auth0.android.jwt.JWT import com.winlator.cmod.BuildConfig import com.winlator.cmod.R import com.winlator.cmod.app.PluviaApp import com.winlator.cmod.app.db.PluviaDatabase import com.winlator.cmod.app.db.download.DownloadRecord import com.winlator.cmod.app.service.DownloadService -import com.winlator.cmod.app.service.NetworkMonitor import com.winlator.cmod.app.service.download.DownloadCoordinator import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException import com.winlator.cmod.feature.stores.steam.data.CachedLicense import com.winlator.cmod.feature.stores.steam.data.DepotInfo import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException import com.winlator.cmod.feature.stores.steam.data.DownloadInfo -import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo import com.winlator.cmod.feature.stores.steam.data.LaunchInfo import com.winlator.cmod.feature.stores.steam.data.ManifestInfo import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PICSRequest 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.SteamID 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.data.WnDownloadTransientException import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao @@ -42,41 +50,38 @@ import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao -import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase -import com.winlator.cmod.feature.stores.steam.enums.GameSource -import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult import com.winlator.cmod.feature.stores.steam.enums.LoginResult import com.winlator.cmod.feature.stores.steam.enums.Marker import com.winlator.cmod.feature.stores.steam.enums.OS import com.winlator.cmod.feature.stores.steam.enums.OSArch import com.winlator.cmod.feature.stores.steam.enums.SaveLocation import com.winlator.cmod.feature.stores.steam.enums.SyncResult -import com.auth0.android.jwt.JWT -import com.winlator.cmod.feature.stores.common.StoreAuthStatus -import com.winlator.cmod.feature.stores.common.StoreArtworkCache -import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety import com.winlator.cmod.feature.stores.steam.events.AndroidEvent import com.winlator.cmod.feature.stores.steam.events.SteamEvent import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator -import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor -import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback -import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener -import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult -import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator -import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore -import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback -import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession -import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver -import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.sync.withPermit -import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.BACKGROUND_IDLE_GRACE_MS +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.RECONNECT_BACKOFF_CAP_MS +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.generateAchievements +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.installWnLogonObserver +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isConnectedFlow +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isLoggedIn +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isLoggedInFlow +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.onAppBackgrounded +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.onAppForegrounded +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.withWnSession +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.wnSession import com.winlator.cmod.feature.stores.steam.statsgen.StatType import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.KeyValue import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils import com.winlator.cmod.feature.stores.steam.utils.Net @@ -84,30 +89,25 @@ import com.winlator.cmod.feature.stores.steam.utils.PrefManager import com.winlator.cmod.feature.stores.steam.utils.SteamUtils import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp -import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud -import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator import com.winlator.cmod.runtime.container.Container import com.winlator.cmod.runtime.container.ContainerManager import com.winlator.cmod.runtime.display.environment.ImageFs -import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.LogManager 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.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 -import com.winlator.cmod.feature.stores.steam.enums.ELicenseType -import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod -import com.winlator.cmod.feature.stores.steam.enums.EOSType -import com.winlator.cmod.feature.stores.steam.enums.EPersonaState -import com.winlator.cmod.feature.stores.steam.enums.EResult -import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException -import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo -import com.winlator.cmod.feature.stores.steam.data.PICSRequest -import com.winlator.cmod.feature.stores.steam.data.SteamID -import com.winlator.cmod.feature.stores.steam.utils.KeyValue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -120,21 +120,20 @@ import kotlinx.coroutines.async import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlin.coroutines.coroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.future.await import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout import okhttp3.FormBody import okhttp3.Request import org.json.JSONArray @@ -144,18 +143,17 @@ import java.io.File import java.io.IOException import java.io.InputStream import java.io.OutputStream -import java.lang.NullPointerException import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.file.Files import java.nio.file.Paths -import java.util.Collections import java.util.Date import java.util.EnumSet import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject +import kotlin.coroutines.resume import kotlin.io.path.pathString import kotlin.time.Duration.Companion.seconds @@ -196,7 +194,9 @@ class SteamService : Service() { @Inject lateinit var downloadingAppInfoDao: DownloadingAppInfoDao - private lateinit var notificationHelper: NotificationHelper + /*private lateinit var notificationHelper: NotificationHelper + var notificationID = 1 + var preferences: SharedPreferences? = null*/ private var _unifiedFriends: SteamUnifiedFriends? = null @@ -6590,7 +6590,7 @@ class SteamService : Service() { Timber.e("WnSteam auth failed: %s", result.errorMessage) com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient .reportLogonFailure( - eresult = result.errorCode.takeIf { it != 0 } ?: 2 /* Fail */, + eresult = result.errorCode.takeIf { it != 0 } ?: 2, /* Fail */ stillRetrying = false, ) recordLogonFailure(result.errorCode.takeIf { it != 0 } ?: 2) @@ -7071,8 +7071,12 @@ class SteamService : Service() { fun start(context: Context) { try { + val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context) val intent = Intent(context, SteamService::class.java) - context.startForegroundService(intent) + + // Just start as a normal service. KeepAliveService should protect this. + context.startService(intent) + } catch (e: Exception) { Timber.e(e, "Failed to start SteamService") } @@ -7097,11 +7101,13 @@ class SteamService : Service() { if (!isStopping) { isStopping = true runCatching { - steamInstance.stopForeground(Service.STOP_FOREGROUND_REMOVE) + SessionKeepAliveService.stopComponent(steamInstance, SessionKeepAliveService.COMPONENT_STEAM) }.onFailure { Timber.w(it, "Failed to remove SteamService foreground state during shutdown") } - runCatching { - steamInstance.notificationHelper.cancel() - }.onFailure { Timber.w(it, "Failed to cancel SteamService notification during shutdown") } + /*runCatching { + if (steamInstance::notificationHelper.isInitialized) { + steamInstance.notificationHelper.cancel(steamInstance.notificationID) + } + }.onFailure { Timber.w(it, "Failed to cancel SteamService notification during shutdown") }*/ steamInstance.stopSelf() } steamInstance.scope.launch { @@ -7120,8 +7126,11 @@ class SteamService : Service() { PrefManager.clearAuthTokens() instance?.let { svc -> svc.scope.launch(Dispatchers.IO) { - runCatching { svc.encryptedAppTicketDao.deleteAll() } - .onFailure { Timber.w(it, "Failed to clear encrypted-app-ticket cache on logout") } + runCatching { + // Unregister Steam immediately on logout + SessionKeepAliveService.stopComponent(svc, SessionKeepAliveService.COMPONENT_STEAM) + svc.encryptedAppTicketDao.deleteAll() + }.onFailure { Timber.w(it, "Failed to clear encrypted-app-ticket cache on logout") } } } runCatching { @@ -7667,10 +7676,6 @@ class SteamService : Service() { super.onCreate() instance = this - notificationHelper = NotificationHelper(applicationContext) - val notification = notificationHelper.createForegroundNotification("Steam Service is running") - startForeground(1, notification) - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient .seedFromPrefManager(applicationContext) @@ -7778,6 +7783,11 @@ class SteamService : Service() { } } + // Register Steam component in the master foreground service + if (isRunning && !isStopping) { + SessionKeepAliveService.startComponent(this, SessionKeepAliveService.COMPONENT_STEAM, "Connected") + } + return START_STICKY } @@ -7795,8 +7805,11 @@ class SteamService : Service() { downloadInfo.persistProgressSnapshot(force = true) } - stopForeground(STOP_FOREGROUND_REMOVE) - notificationHelper.cancel() + /*stopForeground(STOP_FOREGROUND_REMOVE) + notificationHelper.cancel(notificationID)*/ + + // Safety unregister in case of unexpected destruction + SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM) if (!isStopping) { scope.launch { stop() } @@ -7913,7 +7926,10 @@ class SteamService : Service() { private fun connectionCriticalWork(): String? = when { DownloadCoordinator.hasActiveDownload() -> "a download is active" - PluviaApp.isGameSessionActive() -> "a game session is running" +// PluviaApp.isGameSessionActive() -> "a game session is running" + PluviaApp.isGameSessionActive().also { active -> + LogManager.log("SteamService", this) { "Foreground check: isGameSessionActive = $active" } + } -> "a game session is running" syncInProgressApps.values.any { it.get() } -> "a cloud save sync is in progress" else -> null } @@ -8203,7 +8219,7 @@ class SteamService : Service() { retryAttempt++ val backoffMs = reconnectBackoffMs(retryAttempt) Timber.w("Reconnect scheduled in ${backoffMs}ms (retry $retryAttempt/$MAX_RETRY_ATTEMPTS)") - notificationHelper.notify("Retrying") +// notificationHelper.notify(notificationID, "Retrying") PluviaApp.events.emit(SteamEvent.RemotelyDisconnected) reconnectJob?.cancel() reconnectJob = @@ -8325,7 +8341,9 @@ class SteamService : Service() { .setPersonaState(effectiveState) } - notificationHelper.notify("Connected") +// notificationHelper.notify(notificationID,"Connected") + // Update state in master service + SessionKeepAliveService.startComponent(this, SessionKeepAliveService.COMPONENT_STEAM, "Connected") _loginResult = LoginResult.Success PluviaApp.events.emit(SteamEvent.LogonEnded(PrefManager.username, LoginResult.Success)) @@ -8976,4 +8994,14 @@ class SteamService : Service() { val ticket = getEncryptedAppTicket(appId) ?: return null return Base64.encodeToString(ticket, Base64.NO_WRAP) } + + override fun onTimeout(startId: Int, fstype: Int) { + super.onTimeout(startId, fstype) + Timber.w("SteamService reached 6-hour limit for dataSync foreground service. Stopping gracefully.") + + // Unregister before stopping + SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM) + Companion.stop() + } + } diff --git a/app/src/main/feature/stores/steam/ui/SteamLoginViewModel.kt b/app/src/main/feature/stores/steam/ui/SteamLoginViewModel.kt index 5c886b77b..e3fb7f661 100644 --- a/app/src/main/feature/stores/steam/ui/SteamLoginViewModel.kt +++ b/app/src/main/feature/stores/steam/ui/SteamLoginViewModel.kt @@ -9,6 +9,7 @@ import com.winlator.cmod.feature.stores.steam.events.SteamEvent import com.winlator.cmod.feature.stores.steam.service.SteamService import com.winlator.cmod.feature.stores.steam.ui.data.UserLoginState import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.runtime.system.SessionKeepAliveService import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -281,7 +282,9 @@ class SteamLoginViewModel : ViewModel() { context.stopService(intent) android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ try { - context.startForegroundService(intent) +// context.startForegroundService(intent) + context.startService(intent) + SessionKeepAliveService.startComponent(context, SessionKeepAliveService.COMPONENT_STEAM, "Restarting SteamService in retryConnection") } catch (e: Exception) { Timber.e(e, "Failed to restart SteamService in retryConnection") } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 7eddeb579..36c41a550 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -1,5 +1,7 @@ package com.winlator.cmod.runtime.display; +import static com.winlator.cmod.BuildConfig.APPLICATION_ID; + import android.annotation.SuppressLint; import android.app.ActivityManager; import android.content.Context; @@ -15,7 +17,6 @@ import android.hardware.SensorManager; import android.hardware.input.InputManager; import android.net.Uri; -import android.opengl.GLSurfaceView; import android.text.format.DateFormat; import android.os.Build; import android.os.Bundle; @@ -28,11 +29,7 @@ import android.view.PointerIcon; import android.view.View; import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.CheckBox; import android.widget.FrameLayout; -import android.widget.Spinner; import android.widget.TextView; import org.json.JSONException; @@ -78,6 +75,7 @@ import com.winlator.cmod.runtime.content.ContentProfile; import com.winlator.cmod.runtime.content.ContentsManager; import com.winlator.cmod.runtime.content.AdrenotoolsManager; +import com.winlator.cmod.runtime.system.LogManager; import com.winlator.cmod.shared.android.AppUtils; import com.winlator.cmod.shared.android.AppTerminationHelper; import com.winlator.cmod.shared.ui.toast.WinToast; @@ -151,10 +149,6 @@ import com.winlator.cmod.runtime.display.xserver.WindowManager; import com.winlator.cmod.runtime.display.xserver.XServer; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; @@ -168,7 +162,6 @@ import java.util.List; import java.util.HashSet; import java.util.HashMap; -import java.util.Iterator; import java.util.Locale; import java.util.Timer; import java.util.TimerTask; @@ -180,6 +173,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import cn.sherlock.com.sun.media.sound.SF2Soundbank; +import timber.log.Timber; public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { private static final long STEAM_TERMINATION_GRACE_MS = 10000L; @@ -406,6 +400,7 @@ public boolean isInputSuspended() { private boolean isDarkMode; private boolean enableLogsMenu; + private static final String TAG = "XServerDisplayActivity"; private GuestProgramLauncherComponent guestProgramLauncherComponent; private EnvVars overrideEnvVars; @@ -714,7 +709,7 @@ protected void onNewIntent(Intent intent) { boolean containerChanged = incomingContainerId != 0 && incomingContainerId != currentContainerId; if (shortcutChanged || shortcutUuidChanged || containerChanged) { - Log.d("XServerDisplayActivity", "onNewIntent: launch target changed, cleaning up before recreation"); + LogManager.log("XServerDisplayActivity", "onNewIntent: launch target changed, cleaning up before recreation", this); switchLaunchTargetAfterCleanup(intent); } } @@ -806,7 +801,7 @@ private void handleDebugInjectTap(Intent intent) { private void switchLaunchTargetAfterCleanup(Intent intent) { if (!switchLaunchInProgress.compareAndSet(false, true)) { - Log.d("XServerDisplayActivity", "Switch launch already in progress; ignoring duplicate target intent"); + LogManager.log("XServerDisplayActivity", "Switch launch already in progress; ignoring duplicate target intent", this); return; } @@ -823,7 +818,7 @@ private void switchLaunchTargetAfterCleanup(Intent intent) { performForcedSessionCleanup("switch launch target"); runOnUiThread(() -> { if (isFinishing() || isDestroyed()) { - Log.w("XServerDisplayActivity", "Switch cleanup finished after activity was destroyed"); + LogManager.logW("XServerDisplayActivity", "Switch cleanup finished after activity was destroyed", null, this); return; } setIntent(relaunchIntent); @@ -839,7 +834,7 @@ public void onCreate(Bundle savedInstanceState) { AppUtils.hideSystemUI(this); AppUtils.keepScreenOn(this); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) { - setShowWhenLocked(true); +// setShowWhenLocked(true); setTurnScreenOn(true); } else { getWindow().addFlags( @@ -863,10 +858,17 @@ public void onCreate(Bundle savedInstanceState) { com.winlator.cmod.runtime.system.LogManager.prepareForNewSession(this); preferences = PreferenceManager.getDefaultSharedPreferences(this); + + if (preferences.getBoolean("enable_app_debug", false)) { + if (androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.READ_LOGS) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{ android.Manifest.permission.READ_LOGS }, 1); + } + } + com.winlator.cmod.runtime.system.ApplicationLogGate.refresh(this); applyPreferredRefreshRate(); launchedFromPinnedShortcut = isPinnedShortcutLaunchIntent(getIntent()); - + setContentView(R.layout.xserver_display_activity); xServerDisplayFrame = new FrameLayout(this); xServerDisplayFrame.setId(R.id.FLXServerDisplay); @@ -1115,13 +1117,13 @@ public void handleOnBackPressed() { container = containerManager.getContainerById(containerId); if (container == null) { - Log.e("XServerDisplayActivity", "Failed to retrieve container with ID: " + containerId); + LogManager.logE("XServerDisplayActivity", "Failed to retrieve container with ID: " + containerId, null, this); finish(); return; } if (!containerManager.activateContainer(container)) { - Log.e("XServerDisplayActivity", "Failed to activate container with ID: " + containerId); + LogManager.logE("XServerDisplayActivity", "Failed to activate container with ID: " + containerId, null, this); finish(); return; } @@ -1229,12 +1231,12 @@ public void handleOnBackPressed() { if (newContainerId != container.id) { container = containerManager.getContainerById(newContainerId); if (container == null) { - Log.e("XServerDisplayActivity", "Failed to retrieve overridden container with ID: " + newContainerId); + LogManager.logE("XServerDisplayActivity", "Failed to retrieve overridden container with ID: " + newContainerId, null, this); finish(); return; } if (!containerManager.activateContainer(container)) { - Log.e("XServerDisplayActivity", "Failed to activate overridden container with ID: " + newContainerId); + LogManager.logE("XServerDisplayActivity", "Failed to activate overridden container with ID: " + newContainerId, null, this); finish(); return; } @@ -2262,16 +2264,24 @@ public void onResume() { if (!cleaningUp && !isPaused) { ProcessHelper.resumeAllWineProcesses(); - SessionKeepAliveService.onResumeSession(this); } if (taskManagerPaneVisible && taskManagerTimer == null) { startTaskManagerPolling(); } + + SessionKeepAliveService.onResumeSession(this); + + LogManager.log(TAG, "Session resumed", getApplicationContext()); + handler.postDelayed(LogManager::stopPauseWatch, 5000); } @Override public void onPause() { + LogManager.startPauseWatch(getApplicationContext()); + LogManager.log(TAG, "Session paused; entering background", getApplicationContext()); + SessionKeepAliveService.onPauseSession(this); + super.onPause(); isVolumeUpPressed = false; isVolumeDownPressed = false; @@ -2285,6 +2295,8 @@ public void onPause() { if (!cleaningUp && !isInPictureInPictureMode()) { if (environment != null) { + ProcessHelper.pauseAllWineProcesses(); + environment.onPause(); xServerView.onPause(); } @@ -2431,12 +2443,12 @@ private boolean shouldWatchSteamTermination(int status) { if (!isSteamShortcut() || winHandler == null) return false; if (!steamExitWatchRunning.compareAndSet(false, true)) { - Log.d("XServerDisplayActivity", "Steam exit watch already running; ignoring duplicate termination callback"); + LogManager.log("XServerDisplayActivity", "Steam exit watch already running; ignoring duplicate termination callback", this); return true; } - Log.d("XServerDisplayActivity", - "Steam wrapper terminated with status " + status + "; watching Wine processes before exiting"); + LogManager.log("XServerDisplayActivity", + "Steam wrapper terminated with status " + status + "; watching Wine processes before exiting", this); Executors.newSingleThreadExecutor().execute(() -> { try { @@ -2469,18 +2481,18 @@ private boolean shouldWatchSteamTermination(int status) { } } - Log.d("XServerDisplayActivity", "Steam exit watch snapshot: " + activeNames); + LogManager.log("XServerDisplayActivity", "Steam exit watch snapshot: " + activeNames, this); long now = System.currentTimeMillis(); if (hasNonCoreProcess) { lastNonCoreSeenAt = now; } else if (lastNonCoreSeenAt > 0L && now - lastNonCoreSeenAt >= STEAM_TERMINATION_POLL_MS) { - Log.d("XServerDisplayActivity", "Steam/game processes drained; exiting session"); + LogManager.log("XServerDisplayActivity", "Steam/game processes drained; exiting session", this); requestExitOnUiThread("steam/game processes drained"); return; } else if (lastNonCoreSeenAt < 0L && now - startTime >= STEAM_TERMINATION_GRACE_MS) { - Log.d("XServerDisplayActivity", - "No non-core Steam/game process appeared after wrapper exit; exiting session"); + LogManager.log("XServerDisplayActivity", + "No non-core Steam/game process appeared after wrapper exit; exiting session", this); requestExitOnUiThread("steam wrapper exited without spawning a game"); return; } @@ -2490,12 +2502,12 @@ private boolean shouldWatchSteamTermination(int status) { } if (!exitRequested.get() && !activityDestroyed.get()) { - Log.d("XServerDisplayActivity", "Steam exit watch timed out; exiting session"); + LogManager.log("XServerDisplayActivity", "Steam exit watch timed out; exiting session", this); requestExitOnUiThread("steam exit watch timed out"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); - Log.w("XServerDisplayActivity", "Steam exit watch interrupted", e); + LogManager.logW("XServerDisplayActivity", "Steam exit watch interrupted", e, this); if (!exitRequested.get() && !activityDestroyed.get()) { requestExitOnUiThread("steam exit watch interrupted"); } @@ -2509,29 +2521,29 @@ private boolean shouldWatchSteamTermination(int status) { private void cleanupLingeringSessionProcesses(String reason) { if (SessionKeepAliveService.isSessionActive()) { - Log.d("XServerDisplayActivity", "Skipping lingering process cleanup from " + reason + " — session is active in background"); + LogManager.log("XServerDisplayActivity", "Skipping lingering process cleanup from " + reason + " — session is active in background", this); return; } ArrayList before = ProcessHelper.listRunningWineProcesses(); if (before.isEmpty()) return; - Log.w("XServerDisplayActivity", "Cleaning lingering session processes before " + reason + ": " - + ProcessHelper.listRunningWineProcessDetails()); + LogManager.logW("XServerDisplayActivity", "Cleaning lingering session processes before " + reason + ": " + + ProcessHelper.listRunningWineProcessDetails(), null, this); ArrayList remaining = ProcessHelper.terminateSessionProcessesAndWait(2000, true); ProcessHelper.drainDeadChildren("pre-launch cleanup"); ProcessHelper.scheduleDeadChildReapSweep("pre-launch cleanup", 2000, 200); if (!remaining.isEmpty()) { - Log.e("XServerDisplayActivity", "Session cleanup still has remaining processes after " + reason + ": " - + ProcessHelper.listRunningWineProcessDetails()); + LogManager.logE("XServerDisplayActivity", "Session cleanup still has remaining processes after " + reason + ": " + + ProcessHelper.listRunningWineProcessDetails(), null, this); } else { - Log.i("XServerDisplayActivity", "No lingering session processes remain after " + reason); + LogManager.logI("XServerDisplayActivity", "No lingering session processes remain after " + reason, this); } } private void requestExitOnUiThread(String reason) { runOnUiThread(() -> { if (activityDestroyed.get() || isFinishing() || isDestroyed()) { - Log.d("XServerDisplayActivity", "Skipping exit request after teardown: " + reason); + LogManager.log("XServerDisplayActivity", "Skipping exit request after teardown: " + reason, this); return; } exit(); @@ -2540,15 +2552,15 @@ private void requestExitOnUiThread(String reason) { private boolean beginSessionCleanup(String trigger) { if (sessionCleanupStarted.compareAndSet(false, true)) { - Log.d("XServerDisplayActivity", "Starting session cleanup from " + trigger); + LogManager.log("XServerDisplayActivity", "Starting session cleanup from " + trigger, this); try { if (perfController != null) perfController.stop(); } catch (Throwable t) { - Log.w("XServerDisplayActivity", "perfController.stop() failed", t); + Timber.w("perfController.stop() failed", t); } return true; } - Log.d("XServerDisplayActivity", "Session cleanup already in progress; ignoring " + trigger); + LogManager.log("XServerDisplayActivity", "Session cleanup already in progress; ignoring " + trigger, this); return false; } @@ -2615,14 +2627,14 @@ private void stopWinHandler(String trigger) { WinHandler handler = winHandler; if (handler == null) return; if (!winHandlerStopped.compareAndSet(false, true)) { - Log.d("XServerDisplayActivity", "WinHandler already stopped; ignoring duplicate request from " + trigger); + LogManager.log("XServerDisplayActivity", "WinHandler already stopped; ignoring duplicate request from " + trigger, this); return; } try { handler.stop(); } catch (Exception e) { - Log.e("XServerDisplayActivity", "Failed to stop WinHandler from " + trigger, e); + LogManager.logE("XServerDisplayActivity", "Failed to stop WinHandler from " + trigger, e, this); } } @@ -2774,13 +2786,13 @@ private long sessionTerminateGraceMs() { private void performForcedSessionCleanup(String trigger) { if (!beginSessionCleanup(trigger)) { - Log.d("XServerLeakCheck", "Forced session cleanup already ran; skipping duplicate request from " + trigger); + LogManager.log("XServerLeakCheck", "Forced session cleanup already ran; skipping duplicate request from " + trigger, this); return; } - Log.w("XServerLeakCheck", "Starting forced session cleanup from " + trigger); - Log.d("XServerLeakCheck", "Forced cleanup initial process snapshot: " - + ProcessHelper.listRunningWineProcessDetails()); + LogManager.logW("XServerLeakCheck", "Starting forced session cleanup from " + trigger, null, this); + LogManager.log("XServerLeakCheck", "Forced cleanup initial process snapshot: " + + ProcessHelper.listRunningWineProcessDetails(), this); try { if (playtimePrefs != null) { @@ -2819,8 +2831,9 @@ private void performForcedSessionCleanup(String trigger) { try { stopWinHandler("forced cleanup (" + trigger + ")"); +// LogManager.log("XServerLeakCheck", "Calling [stopWinHandler]", this); } catch (Exception e) { - Log.e("XServerLeakCheck", "Failed to stop WinHandler during forced cleanup", e); + LogManager.logW("XServerLeakCheck", "Failed to stop WinHandler during forced cleanup", e, this); } try { @@ -2862,11 +2875,11 @@ private void performForcedSessionCleanup(String trigger) { private void exit() { if (activityDestroyed.get() || isFinishing() || isDestroyed()) { - Log.d("XServerDisplayActivity", "Ignoring exit() on torn-down activity"); + LogManager.log("XServerDisplayActivity", "Ignoring exit() on torn-down activity", this); return; } if (!exitRequested.compareAndSet(false, true)) { - Log.d("XServerDisplayActivity", "Exit already in progress; ignoring duplicate request"); + LogManager.log("XServerDisplayActivity", "Exit already in progress; ignoring duplicate request", this); return; } @@ -2902,8 +2915,8 @@ private void exit() { environment.stopEnvironmentComponents(); environment = null; } - Log.d("XServerDisplayActivity", "Process snapshot after environment stop: " - + ProcessHelper.listRunningWineProcessDetails()); + LogManager.log("XServerDisplayActivity", "Process snapshot after environment stop: " + + ProcessHelper.listRunningWineProcessDetails(), this); stopXServer("exit"); wineRequestHandler = null; midiHandler = null; @@ -2944,7 +2957,7 @@ private boolean isPinnedShortcutLaunchIntent(@Nullable Intent intent) { android.net.Uri data = intent.getData(); return data != null && "winnative".equals(data.getScheme()) - && BuildConfig.APPLICATION_ID.equals(data.getAuthority()) + && APPLICATION_ID.equals(data.getAuthority()) && data.getPathSegments().contains("shortcut"); } @@ -3622,6 +3635,7 @@ protected void onDestroy() { if (exitRequested.get()) { SessionKeepAliveService.stopSession(this); } + LogManager.stopPauseWatch(); super.onDestroy(); if (!switchLaunchInProgress.get()) { @@ -3648,7 +3662,7 @@ protected void onDestroy() { Log.w(tag, "Environment not null — components may not have been stopped"); } if (winHandler != null && winHandler.getSocket() != null && !winHandler.getSocket().isClosed()) { - Log.e(tag, "WinHandler socket still open"); + LogManager.logE(tag, "WinHandler socket still open", null, this); } if (wineRequestHandler != null && wineRequestHandler.getServerSocket() != null && !wineRequestHandler.getServerSocket().isClosed()) { Log.e(tag, "WineRequestHandler server socket still open"); @@ -4762,8 +4776,8 @@ private boolean handleDrawerAction(int itemId) { SessionKeepAliveService.onResumeSession(this); } else { - ProcessHelper.pauseAllWineProcesses(); SessionKeepAliveService.onPauseSession(this); + ProcessHelper.pauseAllWineProcesses(); if (touchpadView != null) touchpadView.resetInputState(); if (inputControlsView != null) inputControlsView.cancelActiveTouches(); } @@ -5876,6 +5890,7 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { guestProgramLauncherComponent.setEnvVars(envVars); guestProgramLauncherComponent.setTerminationCallback((status) -> { Log.d("XServerDisplayActivity", "Guest process terminated with status: " + status); + LogManager.log(TAG, "Guest process [" + guestProgramLauncherComponent.getGuestExecutable() + "] terminated with status: " + status, this); stopWnLauncherStatusTailer(); @@ -6685,8 +6700,6 @@ public InputControlsView getInputControlsView() { return inputControlsView; } - private static final String TAG = "DXWrapperExtraction"; - private static final String[] DXWRAPPER_DLLS = { "d3d10.dll", "d3d10_1.dll", "d3d10core.dll", "d3d11.dll", "d3d12.dll", "d3d12core.dll", diff --git a/app/src/main/runtime/display/connector/Client.java b/app/src/main/runtime/display/connector/Client.java index 983b67bbf..888cfeaf6 100644 --- a/app/src/main/runtime/display/connector/Client.java +++ b/app/src/main/runtime/display/connector/Client.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.concurrent.atomic.AtomicBoolean; public class Client { public final ClientSocket clientSocket; @@ -12,13 +13,25 @@ public class Client { private Object tag; protected Thread pollThread; protected int shutdownFd; - protected boolean connected; + protected volatile boolean connected; // was a plain boolean; read/written cross-thread + + // Guards killConnection(): both this client's own poll thread (on EOF/IOException) + // and the connector's teardown thread (XConnectorEpoll.shutdown()) can call + // killConnection() for the same client concurrently. Without a single-entry + // gate, both run the full close sequence, double-closing shutdownFd/clientSocket.fd + // after the OS has already reassigned the number elsewhere (fdsan abort). + private final AtomicBoolean killing = new AtomicBoolean(false); public Client(XConnectorEpoll connector, ClientSocket clientSocket) { this.connector = connector; this.clientSocket = clientSocket; } + /** True the first time this is called for this client; false on every call after. */ + protected boolean markKillingOnce() { + return killing.compareAndSet(false, true); + } + public void createIOStreams() { if (inputStream != null || outputStream != null) return; inputStream = new XInputStream(clientSocket, connector.getInitialInputBufferCapacity()); diff --git a/app/src/main/runtime/display/connector/XConnectorEpoll.java b/app/src/main/runtime/display/connector/XConnectorEpoll.java index f6fbb845b..ead69dbdd 100644 --- a/app/src/main/runtime/display/connector/XConnectorEpoll.java +++ b/app/src/main/runtime/display/connector/XConnectorEpoll.java @@ -130,6 +130,7 @@ public Client getClient(int fd) { } public void killConnection(Client client) { + if (!client.markKillingOnce()) return; // already being/been torn down by another thread client.connected = false; connectionHandler.handleConnectionShutdown(client); if (multithreadedClients) { diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 680720647..4458ac249 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -1,39 +1,184 @@ package com.winlator.cmod.runtime.system +import android.Manifest +import android.app.ActivityManager +import android.app.ApplicationExitInfo import android.content.Context -import android.os.Environment +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build import android.util.Log +import androidx.core.app.ActivityCompat import androidx.preference.PreferenceManager +import com.winlator.cmod.app.config.SettingsConfig +import com.winlator.cmod.shared.io.FileUtils +import timber.log.Timber import java.io.Closeable import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale object LogManager { private const val TAG = "LogManager" + private const val LOG_FILE = "app_debug.log" + private var logcatProcess: Process? = null private var appLogProcess: Process? = null + private var pauseWatchProcess: Process? = null + + private val timestampFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + + enum class Level(val prefix: String) { + DEBUG("D"), INFO("I"), WARN("W"), ERROR("E") + } + + // ── Cached state ────────────────────────────────────────────────── + // + // The whole point of this section: nothing below should ever hit + // SharedPreferences or resolve a URI on a per-log-call basis. Both are + // read once and kept current by a listener, so a disabled or filtered-out + // call costs one volatile-field read, not a disk lookup. + + @Volatile private var appContext: Context? = null + @Volatile var isDebugEnabledCached = false + @Volatile private var cachedLogsDir: File? = null + @Volatile private var cachedAllowedTags: Set = emptySet() + @Volatile private var cachedTextFilters: List = emptyList() + + private var prefsListener: SharedPreferences.OnSharedPreferenceChangeListener? = null + + /** Cheap, public, and the recommended guard for any genuinely expensive log message. */ + @JvmStatic + val isDebugEnabled: Boolean + get() = isDebugEnabledCached + + private fun resolveContext(context: Context?): Context? = context?.applicationContext ?: appContext + + /** + * Call once, ideally from PluviaApp.onCreate(), so every later call + * site — including ones with no Context of their own — has a fallback, + * and so the debug/path-dependent caches above are primed before + * anything tries to log. + */ + @JvmStatic + fun init(context: Context) { + val app = context.applicationContext + appContext = app + refreshCaches(app) + + if (prefsListener == null) { + val prefs = PreferenceManager.getDefaultSharedPreferences(app) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + when (key) { + "enable_app_debug", "winlator_path_uri", "app_debug_tags", "app_debug_text_filter" -> + refreshCaches(app) + } + } + prefs.registerOnSharedPreferenceChangeListener(listener) + prefsListener = listener + } + + Log.d(TAG, "LogManager initialized, context name=${app.javaClass.name}, appContext=$appContext") + + // Set up uncaught exception handler + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + if (isDebugEnabledCached) { + logCrash(app, thread, throwable) + } + // Call the original handler to maintain default behavior + defaultHandler?.uncaughtException(thread, throwable) + } + + // Capture previous exit reasons + if (isDebugEnabledCached && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + logLastExitReasons(app) + } + } + + private fun refreshCaches(context: Context) { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + isDebugEnabledCached = prefs.getBoolean("enable_app_debug", false) + cachedLogsDir = resolveLogsDir(context, prefs) + cachedAllowedTags = prefs.getString("app_debug_tags", null) + ?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet() + ?: emptySet() + cachedTextFilters = prefs.getString("app_debug_text_filter", null) + ?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } + ?: emptyList() + } + + // ── Logs directory ─────────────────────────────────────────────── @JvmStatic fun getLogsDir(context: Context): File { - val baseDir = context.getExternalFilesDir(null) ?: context.filesDir + /*val baseDir = context.getExternalFilesDir(null) ?: context.filesDir val dir = File(baseDir, "logs") + if (!dir.exists()) dir.mkdirs()*/ + + /*val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val currentPath = resolvePathString(prefs.getString("winlator_path_uri", null), SettingsConfig.DEFAULT_WINLATOR_PATH, context) + + val dir = File(currentPath, "logs") + if (!dir.exists()) dir.mkdirs() + + Timber.tag(TAG).d("Winlator path: $ctx") + + return dir*/ + + cachedLogsDir?.let { return it } + val ctx = resolveContext(context) ?: return File(SettingsConfig.DEFAULT_WINLATOR_PATH, "logs").also { + // No context available anywhere yet (init() never called and none + // passed in) — fall back without caching, since we can't listen + // for preference changes without one. + if (!it.exists()) it.mkdirs() + } + + // Use the same user-visible WinNative folder as everything else, + // not the package-private external-files dir, so logs can be + // browsed/pulled without ADB or root. + val dir = resolveLogsDir(ctx, PreferenceManager.getDefaultSharedPreferences(ctx)) + cachedLogsDir = dir + + Timber.tag(TAG).d("Logs dir: $dir") + + return dir + } + + private fun resolveLogsDir(context: Context, prefs: SharedPreferences): File { + val pathString = resolvePathString(prefs.getString("winlator_path_uri", null), context) + val dir = File(pathString, "logs") + + Timber.d("Winnative pathString: $pathString") + if (!dir.exists()) dir.mkdirs() return dir } + private fun resolvePathString(uriStr: String?, context: Context): String { + if (uriStr.isNullOrEmpty()) return SettingsConfig.DEFAULT_WINLATOR_PATH + return try { + FileUtils.getFilePathFromUri(context, Uri.parse(uriStr)) ?: SettingsConfig.DEFAULT_WINLATOR_PATH + } catch (e: Exception) { + Timber.tag(TAG).w("Failed to resolve winlator_path_uri ($uriStr): ${e.message}") + SettingsConfig.DEFAULT_WINLATOR_PATH + } + } + fun isAnyLoggingEnabled(context: Context): Boolean { val prefs = PreferenceManager.getDefaultSharedPreferences(context) return prefs.getBoolean("enable_wine_debug", false) || - prefs.getBoolean("enable_box64_logs", false) || - prefs.getBoolean("enable_fexcore_logs", false) || - prefs.getBoolean("enable_steam_logs", false) || - prefs.getBoolean("enable_input_logs", false) || - prefs.getBoolean("enable_download_logs", false) || - prefs.getBoolean("enable_app_debug", false) + prefs.getBoolean("enable_box64_logs", false) || + prefs.getBoolean("enable_fexcore_logs", false) || + prefs.getBoolean("enable_steam_logs", false) || + prefs.getBoolean("enable_input_logs", false) || + prefs.getBoolean("enable_download_logs", false) || + isDebugEnabledCached } fun updateLoggingState(context: Context) { - if (!isAnyLoggingEnabled(context)) { - stopLogging() - } + if (!isAnyLoggingEnabled(context)) stopLogging() } @JvmStatic @@ -43,8 +188,7 @@ object LogManager { logsDir.listFiles()?.filter { it.name.endsWith(".old.log") }?.forEach { it.delete() } // Rename current .log → .old.log logsDir.listFiles()?.filter { it.name.endsWith(".log") && !it.name.endsWith(".old.log") }?.forEach { file -> - val oldName = file.name.replace(".log", ".old.log") - file.renameTo(File(logsDir, oldName)) + file.renameTo(File(logsDir, file.name.replace(".log", ".old.log"))) } } @@ -63,8 +207,7 @@ object LogManager { return } - val logsDir = getLogsDir(context) - val logFile = File(logsDir, "logcat.log") + val logFile = File(getLogsDir(context), "logcat.log") try { stopLogcat() @@ -75,7 +218,7 @@ object LogManager { ) closeProcessStdin(logcatProcess) } catch (e: Exception) { - Log.e(TAG, "Failed to start logcat: ${e.message}") + Timber.tag(TAG).e("Failed to start logcat: ${e.message}") } } @@ -89,22 +232,18 @@ object LogManager { logcatProcess?.let(::destroyProcess) logcatProcess = null } catch (e: Exception) { - Log.e(TAG, "Failed to stop logcat: ${e.message}") + Timber.tag(TAG).e("Failed to stop logcat: ${e.message}") } } fun clearLogs(context: Context) { - val logsDir = getLogsDir(context) - logsDir.listFiles()?.forEach { it.delete() } + getLogsDir(context).listFiles()?.forEach { it.delete() } } @JvmStatic fun startAppLogging(context: Context) { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - if (!prefs.getBoolean("enable_app_debug", false)) return - - val logsDir = getLogsDir(context) - val logFile = File(logsDir, "application.log") + if (!isDebugEnabledCached) return + val logFile = File(getLogsDir(context), "application.log") try { stopAppLogging() @@ -114,9 +253,9 @@ object LogManager { arrayOf("logcat", "-f", logFile.absolutePath, "--pid=$pid", "*:W"), ) closeProcessStdin(appLogProcess) - Log.i(TAG, "Application debug logging started (PID=$pid)") + Timber.i("Application debug logging started (PID=$pid)") } catch (e: Exception) { - Log.e(TAG, "Failed to start application logging: ${e.message}") + Timber.e("Failed to start application logging: ${e.message}") } } @@ -126,7 +265,7 @@ object LogManager { appLogProcess?.let(::destroyProcess) appLogProcess = null } catch (e: Exception) { - Log.e(TAG, "Failed to stop application logging: ${e.message}") + Timber.e("Failed to stop application logging: ${e.message}") } } @@ -174,4 +313,210 @@ object LogManager { /** Deletes all shareable log files; returns the count removed. */ @JvmStatic fun deleteShareableLogs(context: Context): Int = getShareableLogFiles(context).count { it.delete() } + + // ── 1. Custom breadcrumbs, callable from anywhere ─────────────── + // + // Writes directly to disk (open → write → flush → close on every + // call) instead of going through a buffered writer. This is + // deliberate: if the process gets killed seconds after this call, + // an open-but-unflushed buffer would lose exactly the line need. + // A few extra file opens per session is a non-issue. + // + // Message arguments are still evaluated eagerly by the caller + // for the plain String overloads — for anything expensive to build, + // guard it with `if (LogManager.isDebugEnabled)`, or use the lambda + // overload below from Kotlin. + + @JvmStatic @JvmOverloads + fun log(tag: String, message: String, context: Context? = null) = + baseLog(Level.DEBUG, tag, message, null, context) + + @JvmStatic @JvmOverloads + fun logI(tag: String, message: String, context: Context? = null) = + baseLog(Level.INFO, tag, message, null, context) + + @JvmStatic @JvmOverloads + fun logW(tag: String, message: String, t: Throwable? = null, context: Context? = null) = + baseLog(Level.WARN, tag, message, t, context) + + @JvmStatic @JvmOverloads + fun logE(tag: String, message: String, t: Throwable? = null, context: Context? = null) = + baseLog(Level.ERROR, tag, message, t, context) + + /** + * Kotlin-only sugar for genuinely expensive messages: [message] is never + * invoked at all when debug logging is off. Not exposed to Java — + * inline functions with function-type parameters don't cross that + * boundary cleanly; Java callers should use the isDebugEnabled guard + * instead. + */ + inline fun log(tag: String, context: Context? = null, message: () -> String) { + if (!isDebugEnabledCached) return + baseLog(Level.DEBUG, tag, message(), null, context) + } + + inline fun logI(tag: String, context: Context? = null, message: () -> String) { + if (!isDebugEnabledCached) return + baseLog(Level.INFO, tag, message(), null, context) + } + + inline fun logW(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) { + if (!isDebugEnabledCached) return + baseLog(Level.WARN, tag, message(), null, context) + } + + inline fun logE(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) { + if (!isDebugEnabledCached) return + baseLog(Level.ERROR, tag, message(), null, context) + } + + fun baseLog(level: Level, tag: String, message: String, t: Throwable?, context: Context?) { + // Mirrors Android Log so this can drop in for Log.* call sites. + when (level) { + Level.DEBUG -> Timber.tag(tag).d(message) + Level.INFO -> Timber.tag(tag).i(message) + Level.WARN -> if (t != null) Timber.tag(tag).w(t, message) else Timber.tag(tag).w(message) + Level.ERROR -> if (t != null) Timber.tag(tag).e(t, message) else Timber.tag(tag).e(message) + } + + if (!isDebugEnabledCached) return + if (cachedAllowedTags.isNotEmpty() && tag !in cachedAllowedTags) return + if (cachedTextFilters.isNotEmpty() && cachedTextFilters.none { message.contains(it, ignoreCase = true) }) return + + val ctx = resolveContext(context) ?: return + val fullMessage = if (t != null) "$message :: ${Log.getStackTraceString(t)}" else message + appendLine(ctx, LOG_FILE, "${level.prefix}/$tag", fullMessage) + } + + private fun appendLine(context: Context, fileName: String, level: String, message: String) { + try { + val file = File(getLogsDir(context), fileName) + file.appendText("${timestampFormat.format(Date())} $level: $message\n") + } catch (e: Exception) { + Timber.e("Failed to append to $fileName: ${e.message}") + } + } + + // ── 2. Pause/resume window capture ─────────────────────────────── + // + // Brackets exactly the period you care about: screen-lock to + // screen-unlock. Without android.permission.READ_LOGS granted via + // adb, this only ever sees your own UID's lines (your own Log.* + // calls, including whatever you route through log()/logWarn() + // above) — still useful for confirming your own lifecycle order. + // WITH the permission granted once over adb, it will also surface + // system lines like ActivityManager's "Killing (adj N): + // " messages, which is the signal of the OS killing a process. + + @JvmStatic + fun startPauseWatch(context: Context) { + if (!isDebugEnabledCached) return + + // Verify READ_LOGS permission at runtime + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_LOGS) + != PackageManager.PERMISSION_GRANTED) { + logW(TAG, null, context) { "READ_LOGS permission not granted, pause watch may not capture system logs" } + } + + stopPauseWatch() + try { + // Wipe the historical buffer so this file only contains lines from + // this pause window onward — not hours of unrelated backlog. + Runtime.getRuntime().exec(arrayOf("logcat", "-c")).waitFor() + + val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val file = File(getLogsDir(context), "pause_$stamp.log") + appendLine(context, file.name, "I/$TAG", "=== pause window started ===") + pauseWatchProcess = Runtime.getRuntime().exec( + arrayOf( + "logcat", "-v", "threadtime", "-f", file.absolutePath, + "ActivityManager:I", "lmkd:I", "OomAdjuster:I", "ActivityTaskManager:I", "Process:I", +// "*:S", // Silence + "*:D", // Debug [Note: This filter drastically increases the chances that the container will close upon returning to it] + ), + ) + closeProcessStdin(pauseWatchProcess) + } catch (e: Exception) { +// Timber.e("Failed to start pause watch: ${e.message}") + logE(TAG, null, context) { "Failed to start pause watch: ${e.message}" } + } + } + + @JvmStatic + fun stopPauseWatch() { + try { + pauseWatchProcess?.let(::destroyProcess) + pauseWatchProcess = null + } catch (e: Exception) { + Timber.e("Failed to stop pause watch: ${e.message}") + } + } + + // ── 3. Why was the process last killed? ────────────────────────── + // + // No special permission needed (API 30+). Call once, early, on + // every app start — it tells you, after the fact, exactly what + // ended the previous process: REASON_LOW_MEMORY (real LMK kill), + // REASON_SIGNALED/REASON_OTHER (often an OEM battery manager), + // REASON_USER_REQUESTED, REASON_CRASH, etc. + + @JvmStatic @JvmOverloads + fun logLastExitReasons(context: Context? = null) { + if (!isDebugEnabledCached) return + val ctx = resolveContext(context) ?: return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + + try { + val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val infos: List = + am.getHistoricalProcessExitReasons(ctx.packageName, 0, 5) + if (infos.isEmpty()) { + appendLine(ctx, "exit_reasons.log", "I/$TAG", "No historical exit info available") + return + } + for (info in infos) { + appendLine( + ctx, "exit_reasons.log", "I/$TAG", + "pid=${info.pid} reason=${info.reason} importance=${info.importance} " + + "desc=${info.description} timestamp=${Date(info.timestamp)}", + ) + if (info.reason == ApplicationExitInfo.REASON_CRASH_NATIVE) { + try { + info.traceInputStream?.use { input -> + appendLine(ctx, "exit_reasons.log", "I/$TAG", "Tombstone Data:\n${input.bufferedReader().readText()}") + } + } catch (e: Exception) { + Timber.e("Failed to read tombstone trace: ${e.message}") + } + } + } + } catch (e: Exception) { + Timber.e("Failed to read exit reasons: ${e.message}") + } + } + + @JvmStatic + fun logCrash(context: Context, thread: Thread, throwable: Throwable) { + try { + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date()) + val fileName = "crash_$timestamp.log" + val file = File(getLogsDir(context), fileName) + + val crashInfo = buildString { + appendLine("=== CRASH DETECTED ===") + appendLine("Thread: ${thread.name} (ID: ${thread.id})") + appendLine("Timestamp: ${Date()}") + appendLine("Exception: ${throwable.javaClass.simpleName}") + appendLine("Message: ${throwable.message}") + appendLine("\nStack Trace:") + appendLine(Log.getStackTraceString(throwable)) + appendLine("\n=== END CRASH ===") + } + + file.writeText(crashInfo) + Timber.e(throwable, "Crash logged to $fileName") + } catch (e: Exception) { + Timber.e(e, "Failed to log crash") + } + } } diff --git a/app/src/main/runtime/system/ProcessHelper.java b/app/src/main/runtime/system/ProcessHelper.java index d48e83e32..06f16862b 100644 --- a/app/src/main/runtime/system/ProcessHelper.java +++ b/app/src/main/runtime/system/ProcessHelper.java @@ -235,12 +235,12 @@ public static void pauseAllWineProcesses() { String normalized = (statData + " " + cmdlineData).toLowerCase(); // Make the OS never OOM-kill the paused process if possible. - setOomScoreAdj(pid, OOM_SCORE_ADJ_PROTECT); +// setOomScoreAdj(pid, OOM_SCORE_ADJ_PROTECT); - if (isCoreProcess(normalized)) { + /*if (isCoreProcess(normalized)) { if (PRINT_DEBUG) Log.d(TAG, "Skipping SIGSTOP for core process: " + process + " (" + normalized + ")"); continue; - } + }*/ suspendProcess(pid); } @@ -252,7 +252,7 @@ public static void resumeAllWineProcesses() { for (String process : processes) { int pid = Integer.parseInt(process); resumeProcess(pid); - setOomScoreAdj(pid, OOM_SCORE_ADJ_DEFAULT); +// setOomScoreAdj(pid, OOM_SCORE_ADJ_DEFAULT); } } @@ -561,7 +561,17 @@ private static String readProcStat(File proc, String pid) { private static String readProcCmdline(File proc, String pid) { try (FileInputStream fr = new FileInputStream(proc + "/" + pid + "/cmdline")) { - byte[] bytes = fr.readAllBytes(); + // readAllBytes() requires API level 33, otherwise, the code will crash + byte[] bytes; + if (android.os.Build.VERSION.SDK_INT >= 33) { + bytes = fr.readAllBytes(); + } + else { + // Alternative for compatibility + bytes = new byte[(int) fr.getChannel().size()]; + fr.read(bytes); + } + return new String(bytes, StandardCharsets.UTF_8).replace('\0', ' '); } catch (IOException e) { return ""; diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 5c573fdc1..9da1dc5ee 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -8,25 +8,34 @@ import android.content.Context; import android.content.Intent; import android.content.pm.ServiceInfo; -import android.net.wifi.WifiManager; import android.os.Build; import android.os.Handler; import android.os.IBinder; import android.os.Looper; -import android.os.PowerManager; import android.util.Log; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; import com.winlator.cmod.R; +import com.winlator.cmod.app.shell.UnifiedActivity; import com.winlator.cmod.runtime.display.XServerDisplayActivity; import com.winlator.cmod.runtime.display.environment.XEnvironment; import com.winlator.cmod.runtime.display.xserver.XServer; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import com.winlator.cmod.shared.android.NotificationHelper; + +import timber.log.Timber; + /** * Foreground service that keeps the WinNative process alive while a wine * session is in the background or while a component download/install is @@ -39,7 +48,12 @@ * "swipe = close" behaviour. */ public class SessionKeepAliveService extends Service { + + private static volatile SessionKeepAliveService instance; private static final String TAG = "SessionKeepAlive"; + private static final String EXTRA_TAG = "tag"; + private static final String ACTION_ENSURE_FOREGROUND = + "com.winlator.cmod.action.ENSURE_FOREGROUND"; private static final String CHANNEL_ID = "winnative_session_keepalive"; @@ -49,7 +63,13 @@ public class SessionKeepAliveService extends Service { private static final String ACTION_SESSION_RESUME = "com.winlator.cmod.action.SESSION_RESUME"; private static final String ACTION_DL_START = "com.winlator.cmod.action.SESSION_DL_START"; private static final String ACTION_DL_STOP = "com.winlator.cmod.action.SESSION_DL_STOP"; - private static final String EXTRA_TAG = "tag"; + private static final String ACTION_UPDATE_COMPONENT = "com.winlator.cmod.action.UPDATE_COMPONENT"; + private static final String ACTION_REMOVE_COMPONENT = "com.winlator.cmod.action.REMOVE_COMPONENT"; + + public static final String COMPONENT_STEAM = "Steam"; + public static final String COMPONENT_EPIC = "Epic"; + public static final String COMPONENT_GOG = "GOG"; + public static final String COMPONENT_CONTAINER = "Container"; private static final AtomicBoolean sessionActive = new AtomicBoolean(false); private static final HashSet activeDownloads = new HashSet<>(); @@ -60,38 +80,123 @@ public class SessionKeepAliveService extends Service { private static boolean isContainerPaused = false; - private PowerManager.WakeLock wakeLock; - private WifiManager.WifiLock wifiLock; - private int notificationId; + // private PowerManager.WakeLock wakeLock; + // private WifiManager.WifiLock wifiLock; + private NotificationHelper notificationHelper; + private int notificationId = -1; + private static final String NOTIFICATION_ID_NAME = "winnative.keepAlive"; + private static boolean isActivityVisible = false; + + // Tracks active components and their notification messages + private static final Map activeComponents = new ConcurrentHashMap<>(); + // Component names constants + + private final Handler protectionHandler = new Handler(Looper.getMainLooper()); private final Runnable protectionRunnable = new Runnable() { @Override public void run() { - if (sessionActive.get()) { - Log.d(TAG, "Running periodic OOM protection for wine processes"); - new Thread(() -> { - try { - ProcessHelper.protectAllWineProcesses(); - } catch (Exception e) { - Log.e(TAG, "Failed to run OOM protection", e); - } - }, "WineOomProtection").start(); - protectionHandler.postDelayed(this, 2 * 60 * 1000L); // Every 2 minutes - } + if (!sessionActive.get() || !isContainerPaused) return; + Timber.tag(TAG).d("Running periodic HEARTBEAT protection for a container session"); + new Thread(() -> { + try { +// ProcessHelper.protectAllWineProcesses(); + LogManager.log(TAG, "Heartbeat: Keeping container alive...", getApplicationContext()); + } catch (Exception e) { + LogManager.logE(TAG, "Periodic HEARTBEAT protection sweep failed", e, getApplicationContext()); + } + }, "WineOomProtection").start(); + protectionHandler.postDelayed(this, 2 * 60 * 1000L); // Every 2 minutes } }; + // =================================================================== + // Container / game session lifecycle + // =================================================================== + public static void startSession(Context ctx) { if (ctx == null) return; sessionActive.set(true); - sendCommand(ctx, ACTION_SESSION_START, null); + isContainerPaused = false; + isActivityVisible = true; + LogManager.log(TAG, "startSession", ctx); + updateForegroundState(ctx); +// sendCommand(ctx, ACTION_SESSION_START, null); } - public static void stopSession(Context ctx) { + public static void onPauseSession(Context ctx) { + if (ctx == null) return; + if (!sessionActive.get()) { + LogManager.logW(TAG, "onPauseSession called with no active session; ignoring", null, ctx); + return; + } + isContainerPaused = true; + isActivityVisible = false; + LogManager.log(TAG, "onPauseSession", ctx); +// startProtectionHeartbeat(); + updateForegroundState(ctx); +// sendCommand(ctx, ACTION_SESSION_PAUSE, null); + } + + public static void onResumeSession(Context ctx) { if (ctx == null) return; - if (sessionActive.compareAndSet(true, false)) { - sendCommand(ctx, ACTION_SESSION_STOP, null); + if (!sessionActive.get()) { + LogManager.logW(TAG, "onResumeSession called with no active session; ignoring", null, ctx); + return; } + isContainerPaused = false; + isActivityVisible = true; + LogManager.log(TAG, "onResumeSession", ctx); + // stopProtectionHeartbeat(); + updateForegroundState(ctx); +// sendCommand(ctx, ACTION_SESSION_RESUME, null); + } + + public static void stopSession(Context ctx) { + if (ctx == null) return; + if (!sessionActive.compareAndSet(true, false)) return; + isContainerPaused = false; +// LogManager.log(ctx, TAG, "stopSession"); + // stopProtectionHeartbeat(); + teardownEnvironmentAsync(); + updateForegroundState(ctx); + LogManager.log(TAG, "Stopping game session in keep-alive service. Request by: " + Objects.requireNonNull(ctx.getClass().getName()), ctx); +// sendCommand(ctx, ACTION_SESSION_STOP, null); + } + + public static boolean isSessionActive() { + return sessionActive.get(); + } + + // Possibly, this method is useless because it does not restart + // in the background unless another class calls this class. + private static void startProtectionHeartbeat() { + SessionKeepAliveService svc = instance; + if (svc == null) return; + svc.protectionHandler.removeCallbacks(svc.protectionRunnable); + svc.protectionHandler.post(svc.protectionRunnable); + } + + private static void stopProtectionHeartbeat() { + SessionKeepAliveService svc = instance; + if (svc != null) svc.protectionHandler.removeCallbacks(svc.protectionRunnable); + } + + // Capture-then-null before handing off, so a second stopSession() call, + // or a racing reader, can never observe a half-torn-down environment. + private static void teardownEnvironmentAsync() { + final XEnvironment env = activeEnvironment; + activeEnvironment = null; + activeXServer = null; + if (env == null) return; + new Thread(() -> { + try { + env.stopEnvironmentComponents(); + } catch (Exception e) { +// Timber.tag(TAG).e(e, "Failed to stop environment components during session stop"); + LogManager.logE(TAG, "Failed to stop environment components during session stop", e, instance.getApplicationContext()); + } + }, "XServerTeardown").start(); } public static XEnvironment getActiveEnvironment() { @@ -110,26 +215,75 @@ public static void setActiveXServer(XServer xServer) { activeXServer = xServer; } - public static void onPauseSession(Context ctx) { - if (ctx == null) return; - sessionActive.set(true); - sendCommand(ctx, ACTION_SESSION_PAUSE, null); + // =================================================================== + // Store component tracking (Steam/Epic/GOG "I'm doing background work") + // =================================================================== + + public static void startComponent(Context ctx, String componentName, String message) { + if (ctx == null || componentName == null) return; + activeComponents.put(componentName, message != null ? message : ""); + LogManager.log(TAG, "startComponent: " + componentName, ctx); + updateForegroundState(ctx); } - public static void onResumeSession(Context ctx) { - if (ctx == null) return; - sendCommand(ctx, ACTION_SESSION_RESUME, null); + /*public static void startComponent(Context context, String componentName, String message) { + activeComponents.put(componentName, message != null ? message : ""); + + Intent intent = new Intent(context, SessionKeepAliveService.class); + intent.setAction(ACTION_UPDATE_COMPONENT); + intent.putExtra("component_name", componentName); + intent.putExtra("component_message", message); + + try { + context.startService(intent); + } catch (Exception e) { + // SILENT CATCH: This prevents the app from crashing. + // If it fails, it means the app is in background. + // The service will start correctly next time the app goes to foreground. + String logMsg = "BackgroundServiceStartNotAllowed: Could not start master service for " + componentName; + Log.w(TAG, logMsg); + LogManager.logWarn(context, TAG, logMsg, e); + } + }*/ + + public static void stopComponent(Context ctx, String componentName) { + if (ctx == null || componentName == null) return; + if (activeComponents.remove(componentName) == null) return; + LogManager.log(TAG, "stopComponent: " + componentName, ctx); + updateForegroundState(ctx); } + /*public static void stopComponent(Context context, String componentName) { + // 1. Update data + activeComponents.remove(componentName); + + // 2. ONLY send the intent if the service is ALREADY running. + // This avoids starting the service just to stop a component. + if (serviceRunning.get()) { + Intent intent = new Intent(context, SessionKeepAliveService.class); + intent.setAction(ACTION_REMOVE_COMPONENT); + intent.putExtra("component_name", componentName); + try { + context.startService(intent); + } catch (Exception e) { + Log.w(TAG, "Failed to send stop component to master service (App in background)"); + } + } + }*/ + + // =================================================================== + // Background download tracking + // =================================================================== + public static void startDownload(Context ctx, String tag) { if (ctx == null) return; String key = tag == null ? "default" : tag; boolean added; - synchronized (activeDownloads) { - added = activeDownloads.add(key); - } + synchronized (activeDownloads) { added = activeDownloads.add(key); } if (added) { - sendCommand(ctx, ACTION_DL_START, key); + Timber.tag(TAG).d("startDownload: %s", key); + updateForegroundState(ctx); +// sendCommand(ctx, ACTION_DL_START, key); } } @@ -137,25 +291,148 @@ public static void stopDownload(Context ctx, String tag) { if (ctx == null) return; String key = tag == null ? "default" : tag; boolean removed; - synchronized (activeDownloads) { - removed = activeDownloads.remove(key); - } + synchronized (activeDownloads) { removed = activeDownloads.remove(key); } if (removed) { - sendCommand(ctx, ACTION_DL_STOP, key); + Timber.tag(TAG).d("stopDownload: %s", key); + updateForegroundState(ctx); +// sendCommand(ctx, ACTION_DL_STOP, key); } } - public static boolean isSessionActive() { - return sessionActive.get(); - } + // =================================================================== + // Foreground validation logic + // =================================================================== private static boolean hasReason() { - if (sessionActive.get()) return true; + return sessionActive.get() || !activeDownloads.isEmpty() || !activeComponents.isEmpty(); + /*if (sessionActive.get()) return true; synchronized (activeDownloads) { return !activeDownloads.isEmpty(); + }*/ + } + + // Single chokepoint for every caller (session, components, downloads). + // Mutates state first, then either talks to the already-alive instance + // directly (no Intent, no restriction — it's just a method call) or, only + // if the service doesn't exist yet, asks the OS to create it. + private static synchronized void updateForegroundState(Context ctx) { + SessionKeepAliveService svc = instance; + + if (hasReason()) { + if (svc != null) { + svc.ensureForeground(); + } else { + Context app = ctx.getApplicationContext(); + Intent intent = new Intent(app, SessionKeepAliveService.class); + intent.setAction(ACTION_ENSURE_FOREGROUND); + try { + androidx.core.content.ContextCompat.startForegroundService(app, intent); + } catch (Exception e) { + LogManager.logW(TAG, "Failed to start keep-alive service", e, ctx); + } + } + } else if (svc != null) { + LogManager.log(TAG, "No active reason remains; stopping keep-alive service", ctx); + svc.stopForegroundCompat(); + svc.stopSelf(); } } + // =================================================================== + // Foreground class logic + // =================================================================== + + @Override + public void onCreate() { + super.onCreate(); + instance = this; + + // Initialize the helper using the application context + notificationHelper = new NotificationHelper(getApplicationContext()); + notificationHelper.createNotificationChannel(); // Replace ensureChannel() method. + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + // State is already current by the time this runs — every caller mutates + // it before this Intent is ever sent. This just reconciles the actual + // foreground/running status against that state. + if (hasReason()) { + ensureForeground(); + serviceRunning.set(true); + } else { + Timber.tag(TAG).d("onStartCommand found no active reason; stopping immediately"); + stopForegroundCompat(); + stopSelf(); + serviceRunning.set(false); + } + return START_NOT_STICKY; + } + + private void ensureForeground() { + boolean containerActive = sessionActive.get(); + // Only show Exit button if container is running AND app is in background + boolean showExit = containerActive && !isActivityVisible; + + // Determine target activity: Game screen if active, else Main menu + Class targetActivity = containerActive ? XServerDisplayActivity.class : UnifiedActivity.class; + + Notification n = notificationHelper.createForegroundNotification( + getNotificationContent(), + "WinNative", // Title + SessionKeepAliveService.class, // Service class for the 'Exit' action + showExit ? ACTION_SESSION_STOP : null, // Exit only for backgrounded container + targetActivity // Activity class for the 'Open' (notification tap) action + ); + + // Cache the ID: repeated startForeground() calls with the same ID update + // the existing notification instead of risking a fresh one each time + // pause/resume/component state changes. + if (notificationId == -1) { + notificationId = notificationHelper.generateNotificationId(this, NOTIFICATION_ID_NAME); + } + + try { + // Only call startForeground the first time. Use notify() for updates. + if (!serviceRunning.get()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(notificationId, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + } + else { + startForeground(notificationId, n); + } + } + else { + // Standard notification update + notificationHelper.notify(notificationId, n); + } + } catch (Exception e) { + LogManager.logW(TAG, "Failed to startForeground", e, this); + } + } + + private void stopForegroundCompat() { + try { + stopForeground(STOP_FOREGROUND_REMOVE); + } catch (Exception e) { + LogManager.logW(TAG, "Failed to stopForeground", e, this); + } + } + + @Override + public void onTimeout(int startId, int fstype) { + super.onTimeout(startId, fstype); + Timber.tag(TAG).w("Service reached 6-hour limit for dataSync. Stopping gracefully."); + + // Stop the service and cleanup + sessionActive.set(false); + isContainerPaused = false; + // stopProtectionHeartbeat(); + stopForegroundCompat(); + stopSelf(); + super.onTimeout(startId, fstype); + } + private static void sendCommand(Context ctx, String action, @Nullable String tag) { Context app = ctx.getApplicationContext(); Intent intent = new Intent(app, SessionKeepAliveService.class); @@ -163,7 +440,7 @@ private static void sendCommand(Context ctx, String action, @Nullable String tag if (tag != null) intent.putExtra(EXTRA_TAG, tag); try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - (ACTION_SESSION_PAUSE.equals(action) || ACTION_SESSION_START.equals(action) || ACTION_DL_START.equals(action))) { + (ACTION_SESSION_START.equals(action) || ACTION_DL_START.equals(action))) { app.startForegroundService(intent); } else { app.startService(intent); @@ -177,46 +454,59 @@ private static void sendCommand(Context ctx, String action, @Nullable String tag } } - @Override +/* @Override public void onCreate() { + LogManager.logLastExitReasons(getApplicationContext()); + super.onCreate(); - generateNotificationId(); +// generateNotificationId(); + instance = this; + + // Initialize the helper using the application context + notificationHelper = new NotificationHelper(getApplicationContext()); + notificationHelper.createNotificationChannel(); // Replace ensureChannel() method. // Keep the CPU alive to prevent OS from killing the process when the screen is off. - PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + *//*PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); if (pm != null) { wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WinNative:KeepAlive"); -// wakeLock.acquire(); - } + }*//* // Keep the Wi-Fi alive to prevent network interruptions. Useful for games that stream assets from the network or have online features. - WifiManager wm = (WifiManager) getSystemService(WIFI_SERVICE); + *//*WifiManager wm = (WifiManager) getSystemService(WIFI_SERVICE); if (wm != null) { int lockType = WifiManager.WIFI_MODE_FULL; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { lockType = WifiManager.WIFI_MODE_FULL_HIGH_PERF; } wifiLock = wm.createWifiLock(lockType, "WinNative:WifiKeepAlive"); - } + }*//* - ensureChannel(); - } +// ensureChannel(); + }*/ - @Override + /*@Override public int onStartCommand(Intent intent, int flags, int startId) { String action = intent != null ? intent.getAction() : null; if (ACTION_SESSION_START.equals(action)) { sessionActive.set(true); isContainerPaused = false; - protectionHandler.removeCallbacks(protectionRunnable); - protectionHandler.post(protectionRunnable); } else if (ACTION_SESSION_PAUSE.equals(action)) { isContainerPaused = true; + protectionHandler.removeCallbacks(protectionRunnable); + protectionHandler.post(protectionRunnable); } else if (ACTION_SESSION_RESUME.equals(action)) { isContainerPaused = false; - } - else if (ACTION_SESSION_STOP.equals(action)) { + protectionHandler.removeCallbacks(protectionRunnable); + } else if (ACTION_UPDATE_COMPONENT.equals(action)) { + String name = intent.getStringExtra("component_name"); + String msg = intent.getStringExtra("component_message"); + if (name != null) activeComponents.put(name, msg); + } else if (ACTION_REMOVE_COMPONENT.equals(action)) { + String name = intent.getStringExtra("component_name"); + if (name != null) activeComponents.remove(name); + } else if (ACTION_SESSION_STOP.equals(action)) { sessionActive.set(false); isContainerPaused = false; protectionHandler.removeCallbacks(protectionRunnable); @@ -228,75 +518,90 @@ else if (ACTION_SESSION_STOP.equals(action)) { try { env.stopEnvironmentComponents(); } catch (Exception e) { - Log.e(TAG, "Failed to stop environment components during session stop", e); + LogManager.logError(this, TAG, "Failed to stop environment components during session stop", e); } }, "XServerTeardown").start(); } } // Ensure wake lock, wifi lock and OOM adj are correct based on current state - if (hasReason()) { - if (wakeLock != null && !wakeLock.isHeld()) wakeLock.acquire(); - if (wifiLock != null && !wifiLock.isHeld()) wifiLock.acquire(); - ProcessHelper.setOomScoreAdj(android.os.Process.myPid(), -1000); - new Thread(() -> { + *//*if (hasReason()) { +// if (wakeLock != null && !wakeLock.isHeld()) wakeLock.acquire(); +// if (wifiLock != null && !wifiLock.isHeld()) wifiLock.acquire(); +// ProcessHelper.setOomScoreAdj(android.os.Process.myPid(), -1000); + *//**//*new Thread(() -> { try { ProcessHelper.protectAllWineProcesses(); } catch (Exception e) { Log.e(TAG, "Failed to run initial OOM protection", e); } - }, "InitialWineOomProtection").start(); + }, "InitialWineOomProtection").start();*//**//* } else { - if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); - if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); - ProcessHelper.setOomScoreAdj(android.os.Process.myPid(), 0); - } - - // Always promote to foreground first so Android does not consider - // the start a violation (and so the notification reflects current - // reasons), even if the command immediately tells us to stop. - ensureForeground(); - serviceRunning.set(true); +// if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); +// if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); +// ProcessHelper.setOomScoreAdj(android.os.Process.myPid(), 0); + }*//* - if (!hasReason()) { - Log.d(TAG, "No active reason; stopping keep-alive service"); + if (hasReason()) { + // Always promote to foreground first so Android does not consider + // the start a violation (and so the notification reflects current + // reasons), even if the command immediately tells us to stop. + ensureForeground(); + serviceRunning.set(true); +// Log.d(TAG, "Service keep alive is running..."); + } + else { + LogManager.log(this, TAG, "No active reason; stopping keep-alive service"); stopForegroundCompat(); stopSelf(); serviceRunning.set(false); } - return START_STICKY; - } + return START_NOT_STICKY; + }*/ + + /*private void ensureForeground() { +// Notification n = buildNotification(); + + boolean containerActive = sessionActive.get(); + // Only show Exit button if container is running AND app is in background + boolean showExit = containerActive && !isActivityVisible; + + // Determine target activity: Game screen if active, else Main menu + Class targetActivity = containerActive ? XServerDisplayActivity.class : UnifiedActivity.class; + + Notification n = notificationHelper.createForegroundNotification( + getNotificationContent(), + "WinNative", // Title + SessionKeepAliveService.class, // Service class for the 'Exit' action + showExit ? ACTION_SESSION_STOP : null, // Exit only for backgrounded container + targetActivity // Activity class for the 'Open' (notification tap) action + ); + notificationId = notificationHelper.generateNotificationId(this, NOTIFICATION_ID_NAME); - private void ensureForeground() { - Notification n = buildNotification(); try { - if (Build.VERSION.SDK_INT >= 34) { + // Only call startForeground the first time. Use notify() for updates. + if (!serviceRunning.get()) { + *//*if (Build.VERSION.SDK_INT >= 34) { startForeground(notificationId, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); - } - else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(notificationId, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + } + else *//*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(notificationId, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + } + else { + startForeground(notificationId, n); + } } else { - startForeground(notificationId, n); + // Standard notification update + notificationHelper.notify(notificationId, n); } - } catch (Exception e) { - Log.w(TAG, "Failed to startForeground", e); - } - } - private void stopForegroundCompat() { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - stopForeground(STOP_FOREGROUND_REMOVE); - } else { - stopForeground(true); - } } catch (Exception e) { - Log.w(TAG, "Failed to stopForeground", e); + LogManager.logWarn(this, TAG, "Failed to startForeground", e); } - } + }*/ - private void ensureChannel() { + public void ensureChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); if (nm == null) return; @@ -313,25 +618,8 @@ private void ensureChannel() { nm.createNotificationChannel(channel); } - private Notification buildNotification() { - boolean container = sessionActive.get(); - boolean dl; - synchronized (activeDownloads) { - dl = !activeDownloads.isEmpty(); - } - String content; - if (container && dl) { - content = "Session paused — downloads continuing in background"; - } else if (container) { - if (isContainerPaused) - content = "Container session is paused"; - else - content = "There is a container session running"; - } else if (dl) { - content = "Downloading components in the background"; - } else { - content = "WinNative is running in the background"; - } + public Notification buildNotification() { + String content = getNotificationContent(); Intent openIntent = new Intent(this, XServerDisplayActivity.class); openIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); @@ -353,16 +641,21 @@ private Notification buildNotification() { .build(); } + // =================================================================== + // Cleaning methods + // =================================================================== + @Override public void onTaskRemoved(Intent rootIntent) { super.onTaskRemoved(rootIntent); - Log.i(TAG, "Task removed (user swipe). Tearing down session and exiting process."); + LogManager.logI(TAG, "Task removed (user swipe). Tearing down session and exiting process.", this); - // Clear reasons so any subsequent re-entry will not keep us alive. sessionActive.set(false); - synchronized (activeDownloads) { - activeDownloads.clear(); - } + isContainerPaused = false; + isActivityVisible = false; + synchronized (activeDownloads) { activeDownloads.clear(); } + activeComponents.clear(); + // stopProtectionHeartbeat(); // Give the activity's own onDestroy → performForcedSessionCleanup a // chance to run first; then defensively clean any wine processes that @@ -371,40 +664,37 @@ public void onTaskRemoved(Intent rootIntent) { new Thread(() -> { try { Thread.sleep(1500L); - if (com.winlator.cmod.feature.stores.steam.service.SteamService - .Companion.isBionicHandoffActive()) { + if (com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isBionicHandoffActive()) { try { boolean kicked = com.winlator.cmod.feature.stores.steam.service.SteamService .Companion.bionicHandoffReleaseAndKickPlayingSessionBlocking(true, 2500L); - Log.i(TAG, "Task removal Steam cleanup: kickedPlayingSession=" + kicked); + LogManager.logI(TAG, "Task removal Steam cleanup: kickedPlayingSession=" + kicked, this); } catch (Throwable t) { - Log.w(TAG, "Task removal Steam cleanup failed", t); + LogManager.logW(TAG, "Task removal Steam cleanup failed", t, this); } } ProcessHelper.terminateSessionProcessesAndWait(1500, true); ProcessHelper.drainDeadChildren("session keep-alive task removed"); } catch (Throwable t) { - Log.w(TAG, "Defensive wine cleanup on task removal failed", t); + LogManager.logW(TAG, "Defensive wine cleanup on task removal failed", t, this); } new Handler(Looper.getMainLooper()).post(() -> { stopForegroundCompat(); stopSelf(); serviceRunning.set(false); - // Match the previous swipe behaviour: actually exit the process. - // We do this in a slight delay to ensure stopSelf() has processed. - new Handler(Looper.getMainLooper()).postDelayed(() -> { - android.os.Process.killProcess(android.os.Process.myPid()); - }, 500L); + new Handler(Looper.getMainLooper()).postDelayed( + () -> android.os.Process.killProcess(android.os.Process.myPid()), 500L); }); }, "SessionKeepAliveCleanup").start(); } @Override public void onDestroy() { - protectionHandler.removeCallbacks(protectionRunnable); - if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); - if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); + // stopProtectionHeartbeat(); +// if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); +// if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); serviceRunning.set(false); + instance = null; super.onDestroy(); } @@ -414,9 +704,50 @@ public IBinder onBind(Intent intent) { return null; } - private void generateNotificationId() { + /*public int generateNotificationId() { // Generate a unique ID based on the package name to avoid conflicts with other forks/flavors. String contextKey = getPackageName() + ".winnative.keepAlive"; - notificationId = contextKey.hashCode() & 0x7FFFFFFF; // Avoid negative IDs + return notificationId = contextKey.hashCode() & 0x7FFFFFFF; // Avoid negative IDs + }*/ + + // =================================================================== + // Utility methods + // =================================================================== + + @NonNull + private static String getNotificationContent() { + // 1. HIGHEST PRIORITY: The game/container + if (sessionActive.get()) { + return isContainerPaused ? "Container session is paused" : "There is a container session running"; + } + + // 2. MEDIUM PRIORITY: Downloads + synchronized (activeDownloads) { + if (!activeDownloads.isEmpty()) return "Downloading and installing components in the background"; + } + + // 3. LOW PRIORITY: Active Stores + if (!activeComponents.isEmpty()) { + List names = new ArrayList<>(activeComponents.keySet()); + return names.size() == 1 + ? names.get(0) + " service is active" + : String.join(" and ", names) + " services are active"; + } + return "WinNative is running in the background"; + } + + public static void stopAll(Context ctx) { + stopSession(ctx); + activeComponents.clear(); + + // Stop Steam specifically + com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.stop(); + + // Finally stop the master service + SessionKeepAliveService svc = instance; + if (svc != null) { + svc.stopForegroundCompat(); + svc.stopSelf(); + } } } diff --git a/app/src/main/shared/android/NotificationHelper.kt b/app/src/main/shared/android/NotificationHelper.kt index e3105288d..9b6fba3e6 100644 --- a/app/src/main/shared/android/NotificationHelper.kt +++ b/app/src/main/shared/android/NotificationHelper.kt @@ -6,14 +6,14 @@ import android.app.PendingIntent import android.content.Context import android.content.Context.NOTIFICATION_SERVICE import android.content.Intent +import android.os.Build import androidx.core.app.NotificationCompat import com.winlator.cmod.BuildConfig import com.winlator.cmod.R import com.winlator.cmod.app.shell.UnifiedActivity import com.winlator.cmod.feature.stores.steam.service.SteamService -import com.winlator.cmod.feature.stores.steam.utils.PrefManager import javax.inject.Inject -import javax.inject.Singleton + class NotificationHelper @Inject @@ -23,7 +23,9 @@ class NotificationHelper companion object { private const val CHANNEL_ID = "pluvia_foreground_service" private const val CHANNEL_NAME = "WinNative Foreground Service" - private const val NOTIFICATION_ID = 1 + + // This constant is passed directly from the class that starts the notification. + //private const val NOTIFICATION_ID = 1 const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".EXIT" } @@ -35,67 +37,148 @@ class NotificationHelper createNotificationChannel() } - private fun createNotificationChannel() { - val channel = - NotificationChannel( - CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = "Allows to display WinNative foreground notifications" - setShowBadge(false) - } + // Sends or updates a notification. + fun notify( + id: Int, + content: String, + title: String = context.getString(R.string.common_ui_app_name), + serviceClass: Class<*>? = null, + exitAction: String? = null + ) { + val notification = createForegroundNotification(content, title, serviceClass, exitAction) + notificationManager.notify(id, notification) + } - notificationManager.createNotificationChannel(channel) - } + // Overload to notify using a pre-built Notification object. + fun notify(id: Int, notification: Notification) { + notificationManager.notify(id, notification) + } + + fun cancel(id: Int) { + notificationManager.cancel(id) + } - fun notify(content: String) { - val notification = createForegroundNotification(content) - notificationManager.notify(NOTIFICATION_ID, notification) + fun createForegroundNotification( + content: String, + title: String = context.getString(R.string.common_ui_app_name), + serviceClass: Class<*>? = null, + exitAction: String? = null, + targetActivity: Class<*> = UnifiedActivity::class.java + ): Notification { + val intent = Intent(context, targetActivity).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP } - fun cancel() { - notificationManager.cancel(NOTIFICATION_ID) + val pendingIntent = PendingIntent.getActivity( + context, 0, intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(title) + .setContentText(content) + .setSmallIcon(R.drawable.ic_notification) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setAutoCancel(false) + .setOngoing(true) + .setContentIntent(pendingIntent) + + // Add "Exit" button only if service and action are provided + if (serviceClass != null && exitAction != null) { + val stopIntent = Intent(context, serviceClass).apply { action = exitAction } + val stopPendingIntent = PendingIntent.getForegroundService( + context, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE + ) + builder.addAction(0, "Exit", stopPendingIntent) } - fun createForegroundNotification(content: String): Notification { - val intent = - Intent(context, UnifiedActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - } + return builder.build() + } + + /** + * Create a notification channel. + * @param context The context of the app. + * @param channelId Unique channel identifier. + * @param name Visible channel name for the user. + * @param importance Importance level (e.g., NotificationManager.IMPORTANCE_LOW). + * @param desc Channel description (optional). + */ + fun createNotificationChannel( + context: Context, + channelId: String?, + name: String?, + importance: Int, + desc: String + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val nm = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager? + if (nm != null && nm.getNotificationChannel(channelId) == null) { + val channel = + NotificationChannel( + channelId, + name, + importance + ).apply { + if (!desc.isEmpty()) description = "" + setShowBadge(false) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + } - val pendingIntent = - PendingIntent.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val stopIntent = - Intent(context, SteamService::class.java).apply { - action = ACTION_EXIT + nm.createNotificationChannel(channel) } - val stopPendingIntent = - PendingIntent.getForegroundService( - context, - 0, - stopIntent, - PendingIntent.FLAG_IMMUTABLE, - ) - - val smallIconRes = R.drawable.ic_notification - - return NotificationCompat - .Builder(context, CHANNEL_ID) - .setContentTitle(context.getString(R.string.common_ui_app_name)) - .setContentText(content) - .setSmallIcon(smallIconRes) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setAutoCancel(false) - .setOngoing(true) - .setContentIntent(pendingIntent) - .addAction(0, "Exit", stopPendingIntent) - .build() + } + } + + /** + * Overload of createNotificationChannel() + * without description. + */ + fun createNotificationChannel( + context: Context, + channelId: String?, + name: String?, + importance: Int + ) { + createNotificationChannel(context, channelId, name, importance, "") } + + /** + * Overload of createNotificationChannel() + * using default values. + */ + fun createNotificationChannel() { + createNotificationChannel( + context, + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW, + "Allows to display WinNative foreground notifications" + ) + + /*val channel = + NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Allows to display WinNative foreground notifications" + setShowBadge(false) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + } + + notificationManager.createNotificationChannel(channel)*/ } + + /** + * Generate a unique ID based on the package name and the given string + * to avoid conflicts with other forks/flavors. + * @param context The context of the app for get the package name. + * @param notificationIDName A string that identifies the notification and is used + * to generate a unique ID. + * @return A unique integer identifier. + */ + fun generateNotificationId(context: Context, notificationIDName: String): Int { + val contextKey = context.packageName + notificationIDName + return contextKey.hashCode() and 0x7FFFFFFF // Avoid negative IDs + } +} From 7c25fb0898a0914f315985f0fa525f010cfa3f66 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sun, 28 Jun 2026 11:04:04 +0200 Subject: [PATCH 02/19] Merge with main (resolved conflicts in XServerDisplayActivity.java) Other changes: - Removed unused imports. - Replaced a `Log.w` with `Timber.w`. --- .../main/runtime/display/XServerDisplayActivity.java | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 8a9d7542c..3280a2bf4 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -15,7 +15,6 @@ import android.hardware.SensorManager; import android.hardware.input.InputManager; import android.net.Uri; -import android.opengl.GLSurfaceView; import android.text.format.DateFormat; import android.os.Build; import android.os.Bundle; @@ -28,11 +27,7 @@ import android.view.PointerIcon; import android.view.View; import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.CheckBox; import android.widget.FrameLayout; -import android.widget.Spinner; import android.widget.TextView; import org.json.JSONException; @@ -163,10 +158,6 @@ import com.winlator.cmod.runtime.display.xserver.WindowManager; import com.winlator.cmod.runtime.display.xserver.XServer; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; @@ -180,7 +171,6 @@ import java.util.List; import java.util.HashSet; import java.util.HashMap; -import java.util.Iterator; import java.util.Locale; import java.util.Timer; import java.util.TimerTask; @@ -2642,7 +2632,7 @@ private boolean beginSessionCleanup(String trigger) { try { if (perfController != null) perfController.stop(); } catch (Throwable t) { - Timber.w("perfController.stop() failed", t); + Timber.w(t, "perfController.stop() failed"); } return true; } From 05c4e85b17733e007c9d8d2b46ef914102992772 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Wed, 1 Jul 2026 10:13:29 +0200 Subject: [PATCH 03/19] Enhance logging system. This commit introduces a more robust logging and diagnostic framework. - Automated Tag Collection: Added `gradle/collectLogTags.gradle` to scan source files for log tags and generate a `GeneratedLogTags` object at build time, ensuring the UI always has an up-to-date list of available tags. - Enhanced LogManager: - Implemented granular tag filtering with support for `ALL`, `INCLUDE`, and `EXCLUDE` modes. - Added support for manual text filters and custom user-defined tags. - Expanded diagnostic logging to include dedicated files for native crashes (`crash.log`) and detailed application exit reasons (`exit_reasons.log`) using `ApplicationExitInfo`. - Refactored `startPauseWatch` into `startEventWatch`, providing better logcat filter specifications and label-based log files. - UI Integration: Updated `DebugFragment` and `DebugScreen` to provide controls for the new logging features, including tag selection, filter mode toggles, and manual text filtering. - Build System: Integrated the `collectLogTags` task into the `app` module's `preBuild` step and configured source sets to include generated code. --- app/build.gradle | 10 +- .../feature/settings/debug/DebugFragment.kt | 31 ++ .../feature/settings/debug/DebugScreen.kt | 507 ++++++++++++++++-- app/src/main/res/values/strings.xml | 15 + app/src/main/runtime/system/LogManager.kt | 301 ++++++++--- gradle/collectLogTags.gradle | 104 ++++ 6 files changed, 853 insertions(+), 115 deletions(-) create mode 100644 gradle/collectLogTags.gradle diff --git a/app/build.gradle b/app/build.gradle index 12c190c14..ba0454043 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,6 +8,8 @@ plugins { alias(libs.plugins.spotless) } +apply from: rootProject.file('gradle/collectLogTags.gradle') + /* abstract class InstallVulkanValidationLayerTask extends DefaultTask { @Input @@ -59,6 +61,10 @@ task checkSubmodules { preBuild.dependsOn(checkSubmodules) +tasks.named('preBuild') { + dependsOn 'collectLogTags' +} + /* def installVulkanValidationLayer = tasks.register("installVulkanValidationLayer", InstallVulkanValidationLayerTask) { layerVersion.set("1.4.341.0") @@ -205,11 +211,13 @@ android { sourceSets { main { java.srcDirs = [ +// 'src/main/java', // Needed for Android Studio 'src/main/app', 'src/main/feature', 'src/main/sharedmemory', 'src/main/runtime', - 'src/main/shared' + 'src/main/shared', + 'build/generated/source/logtags' // Generated by collectLogTags.gradle ] } } diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index 7a5ee4f76..e4acdbd6a 100644 --- a/app/src/main/feature/settings/debug/DebugFragment.kt +++ b/app/src/main/feature/settings/debug/DebugFragment.kt @@ -1,5 +1,6 @@ // Settings > Debug fragment — hosts DebugScreen via ComposeView. package com.winlator.cmod.feature.settings +import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.os.Bundle @@ -13,6 +14,7 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView @@ -25,6 +27,8 @@ import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import com.winlator.cmod.R import com.winlator.cmod.app.config.SettingsConfig +import com.winlator.cmod.runtime.system.GeneratedLogTags +import com.winlator.cmod.runtime.system.LogManager import com.winlator.cmod.shared.io.AssetPaths import com.winlator.cmod.shared.io.FileUtils import com.winlator.cmod.shared.io.StorageUtils @@ -80,9 +84,29 @@ class DebugFragment : Fragment() { .stopAppLogging() com.winlator.cmod.runtime.system.LogManager .updateLoggingState(ctx) + com.winlator.cmod.runtime.system.LogManager + .clearManualTextFilter() } refresh() }, + onExitReasonLogChanged = { checked -> + preferences.edit { putBoolean("enable_exit_reason_log", checked) } + refresh() + }, + onCrashLogChanged = { checked -> + preferences.edit { putBoolean("enable_crash_log", checked) } + refresh() + }, + onEventWatchLogChanged = { checked -> + preferences.edit { putBoolean("enable_event_watch_log", checked) } + refresh() + }, + onTagFilterModeChanged = { mode -> LogManager.setTagFilterMode(requireContext(), mode); refresh() }, + onSelectedTagsChanged = { tags -> LogManager.setSelectedTags(requireContext(), tags.toSet()); refresh() }, + onAddCustomTag = { tag -> LogManager.addCustomTag(requireContext(), tag); refresh() }, + onRemoveCustomTag = { tag -> LogManager.removeCustomTag(requireContext(), tag); refresh() }, + onManualTextFilterChanged = { text -> LogManager.setManualTextFilter(text) }, // no persistence, no refreshState needed + allLogTagOptions = remember { LogManager.getAllKnownTags() }, onWineDebugChanged = { checked -> preferences.edit { putBoolean("enable_wine_debug", checked) } com.winlator.cmod.runtime.system.LogManager @@ -194,6 +218,13 @@ class DebugFragment : Fragment() { debugState = DebugState( appDebug = preferences.getBoolean("enable_app_debug", false), + exitReasonLog = preferences.getBoolean("enable_exit_reason_log", false), + crashLog = preferences.getBoolean("enable_crash_log", false), + eventWatchLog = preferences.getBoolean("enable_event_watch_log", false), + tagFilterMode = LogManager.getTagFilterMode(), + selectedTags = LogManager.getSelectedTags().toList(), + customTags = LogManager.getAllKnownTags().filterNot { it in GeneratedLogTags.TAGS }, + wineDebug = preferences.getBoolean("enable_wine_debug", false), wineChannels = channels, box64Logs = preferences.getBoolean("enable_box64_logs", false), diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index a2ab9ac57..c4651c8c4 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -109,6 +109,15 @@ import com.winlator.cmod.shared.ui.toast.WinToast import com.winlator.cmod.shared.ui.outlinedSwitchColors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import androidx.compose.material3.OutlinedTextField +import androidx.compose.foundation.clickable +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction + +import com.winlator.cmod.runtime.system.LogManager // Palette (mirrors StoresScreen) private val BgDark = Color(0xFF11111C) @@ -125,6 +134,13 @@ private val TextSecondary = Color(0xFF7A8FA8) // State data class DebugState( val appDebug: Boolean = false, + val exitReasonLog: Boolean = false, + val crashLog: Boolean = false, + val eventWatchLog: Boolean = false, + val tagFilterMode: LogManager.TagFilterMode = LogManager.TagFilterMode.ALL, + val selectedTags: List = emptyList(), + val customTags: List = emptyList(), + val wineDebug: Boolean = false, val wineChannels: List = emptyList(), val box64Logs: Boolean = false, @@ -150,7 +166,16 @@ data class LogFileEntry( fun DebugScreen( state: DebugState, wineChannelOptions: List, + allLogTagOptions: List, // LogManager.getAllKnownTags() onAppDebugChanged: (Boolean) -> Unit, + onExitReasonLogChanged: (Boolean) -> Unit, + onCrashLogChanged: (Boolean) -> Unit, + onEventWatchLogChanged: (Boolean) -> Unit, + onTagFilterModeChanged: (LogManager.TagFilterMode) -> Unit, + onSelectedTagsChanged: (List) -> Unit, + onAddCustomTag: (String) -> Unit, + onRemoveCustomTag: (String) -> Unit, + onManualTextFilterChanged: (String) -> Unit, // not persisted — live field only onWineDebugChanged: (Boolean) -> Unit, onWineChannelsChanged: (List) -> Unit, onResetWineChannels: () -> Unit, @@ -172,6 +197,7 @@ fun DebugScreen( onDeleteLogFile: (LogFileEntry) -> Unit, ) { var showChannelsDialog by remember { mutableStateOf(false) } + var showTagFilterDialog by remember { mutableStateOf(false) } var showLogsBrowser by remember { mutableStateOf(false) } val layoutDirection = LocalLayoutDirection.current val navBarPadding = WindowInsets.navigationBars.asPaddingValues() @@ -191,6 +217,23 @@ fun DebugScreen( ) } + if (showTagFilterDialog) { + LogTagFilterDialog( + options = allLogTagOptions, + initiallySelected = state.selectedTags, + initialMode = state.tagFilterMode, + customTags = state.customTags, + onDismiss = { showTagFilterDialog = false }, + onConfirm = { mode, selected -> + onTagFilterModeChanged(mode) + onSelectedTagsChanged(selected) + showTagFilterDialog = false + }, + onAddCustomTag = onAddCustomTag, + onRemoveCustomTag = onRemoveCustomTag, + ) + } + if (showLogsBrowser) { val initialFiles = remember { onListLogFiles() } LogsBrowserDialog( @@ -236,6 +279,70 @@ fun DebugScreen( ) } + item(key = "exit_reason_log_card") { + SettingsToggleCard( + title = stringResource(R.string.settings_debug_exit_reason_log_title), + subtitle = stringResource(R.string.settings_debug_exit_reason_log_subtitle), + icon = Icons.Outlined.BugReport, + checked = state.exitReasonLog, + onCheckedChange = onExitReasonLogChanged, + ) + } + + item(key = "crash_log_card") { + SettingsToggleCard( + title = stringResource(R.string.settings_debug_crash_log_title), + subtitle = stringResource(R.string.settings_debug_crash_log_subtitle), + icon = Icons.Outlined.BugReport, + checked = state.crashLog, + onCheckedChange = onCrashLogChanged, + ) + } + + item(key = "event_watch_log_card") { + SettingsToggleCard( + title = stringResource(R.string.settings_debug_event_watch_log_title), + subtitle = stringResource(R.string.settings_debug_event_watch_log_subtitle), + icon = Icons.Outlined.BugReport, + checked = state.eventWatchLog, + onCheckedChange = onEventWatchLogChanged, + ) + } + + item(key = "log_tag_filter_card") { + LogTagFilterCard( + mode = state.tagFilterMode, + selectedTags = state.selectedTags, + enabled = state.appDebug, + onEdit = { showTagFilterDialog = true }, + onModeChanged = onTagFilterModeChanged, + onRemoveSelectedTag = { tag -> + onSelectedTagsChanged(state.selectedTags - tag) + }, + ) + } + + item(key = "manual_text_filter_field") { + var manualFilterText by remember { mutableStateOf(LogManager.getManualTextFilter()) } + val focusManager = LocalFocusManager.current + OutlinedTextField( + value = manualFilterText, + onValueChange = { + manualFilterText = it + onManualTextFilterChanged(it) + }, + placeholder = { Text(stringResource(R.string.settings_debug_manual_text_filter_hint)) }, + singleLine = true, + enabled = state.appDebug, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .focusProperties { canFocus = state.appDebug }, + ) + } + item(key = "emulation_section") { SectionLabel(stringResource(R.string.settings_debug_section_emulation), modifier = Modifier.padding(top = 8.dp)) } @@ -329,7 +436,10 @@ fun DebugScreen( item(key = "log_actions_row") { Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(IntrinsicSize.Min), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { LogActionButton( @@ -518,6 +628,131 @@ private fun WineChannelsCard( } } +// Log tag/filter channels card (shown when Application Log is enabled) +@Composable +private fun LogTagFilterCard( + mode: LogManager.TagFilterMode, + selectedTags: List, + enabled: Boolean, + onEdit: () -> Unit, + onModeChanged: (LogManager.TagFilterMode) -> Unit, + onRemoveSelectedTag: (String) -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .alpha(if (enabled) 1f else 0.48f) + .clip(RoundedCornerShape(12.dp)) + .background(CardDark) + .border(1.dp, CardBorder, RoundedCornerShape(12.dp)), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(IconBoxBg), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + tint = Accent, + modifier = Modifier.size(17.dp), + ) + } + Spacer(Modifier.width(13.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_debug_log_tag_filter_channel), + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + val modeLabel = when (mode) { + LogManager.TagFilterMode.ALL -> stringResource(R.string.settings_debug_tag_filter_all) + LogManager.TagFilterMode.INCLUDE -> stringResource(R.string.settings_debug_tag_filter_include) + LogManager.TagFilterMode.EXCLUDE -> stringResource(R.string.settings_debug_tag_filter_exclude) + } + Text( + text = modeLabel, + color = TextSecondary, + fontSize = 11.sp, + ) + } + Spacer(Modifier.width(8.dp)) + SmallActionButton( + label = stringResource(R.string.common_ui_select), + textColor = Accent, + onClick = { if (enabled) onEdit() }, + ) + } + Spacer(Modifier.height(10.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = stringResource(R.string.settings_debug_tag_filter_mode), color = TextSecondary, fontSize = 11.sp) + SegmentedControl( + options = listOf( + stringResource(R.string.settings_debug_tag_filter_all), + stringResource(R.string.settings_debug_tag_filter_include), + stringResource(R.string.settings_debug_tag_filter_exclude), + ), + selectedIndex = when (mode) { + LogManager.TagFilterMode.ALL -> 0 + LogManager.TagFilterMode.INCLUDE -> 1 + LogManager.TagFilterMode.EXCLUDE -> 2 + }, + onSelectedIndex = { idx -> + onModeChanged( + when (idx) { + 0 -> LogManager.TagFilterMode.ALL + 1 -> LogManager.TagFilterMode.INCLUDE + else -> LogManager.TagFilterMode.EXCLUDE + }, + ) + }, + ) + } + if (mode != LogManager.TagFilterMode.ALL) { + Spacer(Modifier.height(10.dp)) + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (selectedTags.isEmpty()) { + Text( + text = stringResource(R.string.settings_debug_no_tags_selected), + color = TextSecondary, + fontSize = 11.sp, + ) + } else { + selectedTags.forEach { tag -> + ChannelChip( + label = tag, + onRemove = { if (enabled) onRemoveSelectedTag(tag) }, + ) + } + } + } + } + } + } +} + @Composable private fun ChannelChip( label: String, @@ -599,65 +834,52 @@ private fun SmallActionButton( } } -// Wine debug channel selector dialog +// Generic MultiSelectDialog @Composable -private fun WineChannelsDialog( +private fun MultiSelectDialog( + title: String, options: List, initiallySelected: List, onDismiss: () -> Unit, onConfirm: (List) -> Unit, + extraContent: (@Composable (selected: Set, onToggle: (String) -> Unit) -> Unit)? = null, ) { - val selected = - remember(initiallySelected) { - mutableStateOf(initiallySelected.toSet()) - } + val selected = remember(initiallySelected) { mutableStateOf(initiallySelected.toSet()) } Dialog( onDismissRequest = onDismiss, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - // Parent activity runs edge-to-edge (WindowCompat.setDecorFitsSystemWindows(window, false)), - // so we also take the dialog window edge-to-edge and pad for insets manually below. - // This gives predictable behavior regardless of platform defaults. - decorFitsSystemWindows = false, - ), + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), ) { - // fillMaxSize + safeDrawing inset padding keeps the dialog clear of the - // system status/nav bars and any display cutout on every device. BoxWithConstraints( - modifier = - Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(horizontal = 16.dp, vertical = 12.dp), + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(horizontal = 16.dp, vertical = 12.dp), contentAlignment = Alignment.Center, ) { val availableHeight = maxHeight Box( - modifier = - Modifier - .widthIn(max = 460.dp) - .fillMaxWidth() - .heightIn(max = availableHeight) - .clip(RoundedCornerShape(18.dp)) - .background(CardDark) - .border(1.dp, CardBorder, RoundedCornerShape(18.dp)) - .padding(horizontal = 18.dp, vertical = 16.dp), + modifier = Modifier + .widthIn(max = 460.dp) + .fillMaxWidth() + .heightIn(max = availableHeight) + .clip(RoundedCornerShape(18.dp)) + .background(CardDark) + .border(1.dp, CardBorder, RoundedCornerShape(18.dp)) + .padding(horizontal = 18.dp, vertical = 16.dp), ) { Column(modifier = Modifier.fillMaxHeight()) { Text( - text = stringResource(R.string.settings_debug_wine_debug_channel), + text = title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, ) Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(R.string.settings_debug_channel_toggle_hint), - color = TextSecondary, - fontSize = 12.sp, - ) + // Optional hint area can be provided by caller via extraContent or you can keep a default hint Spacer(Modifier.height(12.dp)) ChannelGrid( @@ -673,6 +895,16 @@ private fun WineChannelsDialog( }, ) + // If caller wants to render extra UI (mode switch, add tag field), call it here. + extraContent?.invoke(selected.value) { channel -> + selected.value = + if (channel in selected.value) { + selected.value - channel + } else { + selected.value + channel + } + } + Spacer(Modifier.height(14.dp)) Row( modifier = Modifier.fillMaxWidth(), @@ -698,6 +930,187 @@ private fun WineChannelsDialog( } } + +// Wine debug channel selector dialog +@Composable +private fun WineChannelsDialog( + options: List, + initiallySelected: List, + onDismiss: () -> Unit, + onConfirm: (List) -> Unit, +) { + MultiSelectDialog( + title = stringResource(R.string.settings_debug_wine_debug_channel), + options = options, + initiallySelected = initiallySelected, + onDismiss = onDismiss, + onConfirm = onConfirm, + extraContent = null // no extra UI needed for wine channels + ) +} + +@Composable +private fun LogTagFilterDialog( + options: List, + initiallySelected: List, + initialMode: LogManager.TagFilterMode, + customTags: List, + onDismiss: () -> Unit, + onConfirm: (LogManager.TagFilterMode, List) -> Unit, + onAddCustomTag: (String) -> Unit, + onRemoveCustomTag: (String) -> Unit, +) { + var mode by remember { mutableStateOf(initialMode) } + var newTagText by remember { mutableStateOf("") } + // Custom tags are just as selectable as the build-discovered ones — merge + // them into the grid instead of only listing them in the management row below. + val allOptions = remember(options, customTags) { (options + customTags).distinct().sorted() } + + MultiSelectDialog( + title = stringResource(R.string.settings_debug_log_tag_filter_channel), + options = allOptions, + initiallySelected = initiallySelected, + onDismiss = onDismiss, + onConfirm = { selected -> onConfirm(mode, selected) }, + extraContent = { selectedSet, onToggle -> + Column { + // Mode switch row + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = stringResource(R.string.settings_debug_tag_filter_mode), color = TextPrimary) + Spacer(Modifier.width(8.dp)) + SegmentedControl( + options = listOf( + stringResource(R.string.settings_debug_tag_filter_all), + stringResource(R.string.settings_debug_tag_filter_include), + stringResource(R.string.settings_debug_tag_filter_exclude), + ), + selectedIndex = when (mode) { + LogManager.TagFilterMode.ALL -> 0 + LogManager.TagFilterMode.INCLUDE -> 1 + LogManager.TagFilterMode.EXCLUDE -> 2 + }, + onSelectedIndex = { idx -> + mode = when (idx) { + 0 -> LogManager.TagFilterMode.ALL + 1 -> LogManager.TagFilterMode.INCLUDE + else -> LogManager.TagFilterMode.EXCLUDE + } + } + ) + } + + Spacer(Modifier.height(12.dp)) + + // Add custom tag field + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = newTagText, + onValueChange = { newTagText = it }, + placeholder = { Text(stringResource(R.string.settings_debug_add_custom_tag_hint)) }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + SmallActionButton( + label = stringResource(R.string.common_ui_add), + textColor = Accent, + onClick = { + val tag = newTagText.trim() + if (tag.isNotEmpty()) { + onAddCustomTag(tag) + newTagText = "" + } + } + ) + } + + // Show custom tags with remove affordance + if (customTags.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + customTags.forEach { tag -> + RemovableTagChip(tag = tag, onRemove = { onRemoveCustomTag(tag) }) + } + } + } + + Spacer(Modifier.height(8.dp)) + } + } + ) +} + +@Composable +private fun SegmentedControl( + options: List, + selectedIndex: Int, + onSelectedIndex: (Int) -> Unit, +) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(SurfaceDark) + .border(1.dp, CardBorder, RoundedCornerShape(10.dp)) + .padding(2.dp), + ) { + options.forEachIndexed { index, label -> + val isSelected = index == selectedIndex + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (isSelected) Accent else Color.Transparent) + .clickable { onSelectedIndex(index) } + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = if (isSelected) Color.White else TextSecondary, + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } + } +} + +@Composable +private fun RemovableTagChip(tag: String, onRemove: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(SurfaceDark) + .border(1.dp, CardBorder, RoundedCornerShape(20.dp)) + .padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + ) { + Text(text = tag, color = TextPrimary, fontSize = 12.sp) + Spacer(Modifier.width(4.dp)) + Box( + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable { onRemove() }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(14.dp), + ) + } + } +} + @Composable private fun ColumnScope.ChannelGrid( options: List, @@ -799,7 +1212,8 @@ private fun RowScope.LogActionButton( }, onTap = { onClick() }, ) - }.padding(horizontal = 10.dp, vertical = 7.dp), + } + .padding(horizontal = 10.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { @@ -991,7 +1405,8 @@ private fun LogsHeaderShareAll(onClick: () -> Unit) { }, onTap = { onClick() }, ) - }.padding(horizontal = 10.dp, vertical = 6.dp), + } + .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -1035,7 +1450,8 @@ private fun LogsHeaderDownloadAll(onClick: () -> Unit) { }, onTap = { onClick() }, ) - }.padding(horizontal = 10.dp, vertical = 6.dp), + } + .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -1079,7 +1495,8 @@ private fun LogsHeaderDeleteAll(onClick: () -> Unit) { }, onTap = { onClick() }, ) - }.padding(horizontal = 10.dp, vertical = 6.dp), + } + .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -1182,7 +1599,8 @@ private fun LogFileRow( .border(1.dp, CardBorder, RoundedCornerShape(10.dp)) .pointerInput(entry.absolutePath) { detectTapGestures(onTap = { onOpen() }) - }.padding(horizontal = 12.dp, vertical = 10.dp), + } + .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { @@ -1418,7 +1836,10 @@ private fun LogContentBody( .clip(RoundedCornerShape(10.dp)) .background(SurfaceDark) .border(1.dp, CardBorder, RoundedCornerShape(10.dp)) - .verticalScrollbar(logScrollState, TextSecondary.copy(alpha = 0.6f)) { scrollbarAlpha } + .verticalScrollbar( + logScrollState, + TextSecondary.copy(alpha = 0.6f) + ) { scrollbarAlpha } .padding(10.dp) .verticalScroll(logScrollState), ) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e03520036..a97bffd0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1059,6 +1059,21 @@ E.g. META for META key, \n Wine Debug Channel Debug + Exit reason log + Record why the app process last terminated + Crash log + Save native crash tombstone traces + Event watch log + Capture a focused system log around pause/resume events + Log tag filter + tags selected + Filter log lines by text… + Mode + All + Include + Exclude + Add custom tag… + No tags selected Enable Wine Debug Logs Write Wine debug output to file Enable Box86/64 Logs diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 4458ac249..dbfa5d72a 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -21,17 +21,34 @@ import java.util.Locale object LogManager { private const val TAG = "LogManager" - private const val LOG_FILE = "app_debug.log" + private const val APP_LOG_FILE = "app_debug.log" + private const val EXIT_REASONS_FILE = "exit_reasons.log" + private const val CRASH_FILE = "crash.log" private var logcatProcess: Process? = null private var appLogProcess: Process? = null - private var pauseWatchProcess: Process? = null + private var eventWatchProcess: Process? = null private val timestampFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) - enum class Level(val prefix: String) { - DEBUG("D"), INFO("I"), WARN("W"), ERROR("E") - } + enum class Level(val prefix: String) { DEBUG("D"), INFO("I"), WARN("W"), ERROR("E") } + + enum class TagFilterMode { ALL, INCLUDE, EXCLUDE } + + // Fixed diagnostic baseline always present in an event-watch capture, + // independent of the app-tag filter — these are system components, not + // app classes, so they don't belong in the same selectable list. + private val BASELINE_SYSTEM_TAGS = listOf( + "ActivityManager:I", "lmkd:I", "OomAdjuster:I", "ActivityTaskManager:I", "Process:I", + ) + + private const val PREF_ENABLE_APP_DEBUG = "enable_app_debug" + private const val PREF_ENABLE_EXIT_REASON_LOG = "enable_exit_reason_log" + private const val PREF_ENABLE_CRASH_LOG = "enable_crash_log" + private const val PREF_ENABLE_EVENT_WATCH_LOG = "enable_event_watch_log" + private const val PREF_TAG_FILTER_MODE = "log_tag_filter_mode" + private const val PREF_SELECTED_TAGS = "app_debug_tags" + private const val PREF_CUSTOM_TAGS = "app_debug_custom_tags" // ── Cached state ────────────────────────────────────────────────── // @@ -41,22 +58,34 @@ object LogManager { // call costs one volatile-field read, not a disk lookup. @Volatile private var appContext: Context? = null - @Volatile var isDebugEnabledCached = false + @Volatile + var cachedAppDebugEnabled = false + @Volatile private var cachedExitReasonLogEnabled = false + @Volatile private var cachedCrashLogEnabled = false + @Volatile private var cachedEventWatchEnabled = false + @Volatile private var cachedTagFilterMode = TagFilterMode.ALL + @Volatile private var cachedSelectedTags: Set = emptySet() + @Volatile private var cachedCustomTags: Set = emptySet() @Volatile private var cachedLogsDir: File? = null - @Volatile private var cachedAllowedTags: Set = emptySet() - @Volatile private var cachedTextFilters: List = emptyList() + + @Volatile private var manualTextFilter: String? = null private var prefsListener: SharedPreferences.OnSharedPreferenceChangeListener? = null + private val RELEVANT_KEYS = setOf( + PREF_ENABLE_APP_DEBUG, PREF_ENABLE_EXIT_REASON_LOG, PREF_ENABLE_CRASH_LOG, + PREF_ENABLE_EVENT_WATCH_LOG, PREF_TAG_FILTER_MODE, PREF_SELECTED_TAGS, + PREF_CUSTOM_TAGS, "winlator_path_uri", + ) + /** Cheap, public, and the recommended guard for any genuinely expensive log message. */ @JvmStatic - val isDebugEnabled: Boolean - get() = isDebugEnabledCached + val isDebugEnabled: Boolean get() = cachedAppDebugEnabled private fun resolveContext(context: Context?): Context? = context?.applicationContext ?: appContext /** - * Call once, ideally from PluviaApp.onCreate(), so every later call + * Call once, ideally from UnifiedActivity.onCreate(), so every later call * site — including ones with no Context of their own — has a fallback, * and so the debug/path-dependent caches above are primed before * anything tries to log. @@ -67,6 +96,19 @@ object LogManager { appContext = app refreshCaches(app) + if (prefsListener == null) { + val prefs = PreferenceManager.getDefaultSharedPreferences(app) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key in RELEVANT_KEYS) refreshCaches(app) + } + prefs.registerOnSharedPreferenceChangeListener(listener) + prefsListener = listener + } + + /*val app = context.applicationContext + appContext = app + refreshCaches(app) + if (prefsListener == null) { val prefs = PreferenceManager.getDefaultSharedPreferences(app) val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> @@ -94,20 +136,27 @@ object LogManager { // Capture previous exit reasons if (isDebugEnabledCached && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { logLastExitReasons(app) - } + }*/ } private fun refreshCaches(context: Context) { val prefs = PreferenceManager.getDefaultSharedPreferences(context) - isDebugEnabledCached = prefs.getBoolean("enable_app_debug", false) + cachedAppDebugEnabled = prefs.getBoolean(PREF_ENABLE_APP_DEBUG, false) + cachedExitReasonLogEnabled = prefs.getBoolean(PREF_ENABLE_EXIT_REASON_LOG, false) + cachedCrashLogEnabled = prefs.getBoolean(PREF_ENABLE_CRASH_LOG, false) + cachedEventWatchEnabled = prefs.getBoolean(PREF_ENABLE_EVENT_WATCH_LOG, false) + cachedTagFilterMode = runCatching { + TagFilterMode.valueOf(prefs.getString(PREF_TAG_FILTER_MODE, null) ?: TagFilterMode.ALL.name) + }.getOrDefault(TagFilterMode.ALL) + cachedSelectedTags = splitPref(prefs, PREF_SELECTED_TAGS) + cachedCustomTags = splitPref(prefs, PREF_CUSTOM_TAGS) cachedLogsDir = resolveLogsDir(context, prefs) - cachedAllowedTags = prefs.getString("app_debug_tags", null) + } + + private fun splitPref(prefs: SharedPreferences, key: String): Set = + prefs.getString(key, null) ?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet() ?: emptySet() - cachedTextFilters = prefs.getString("app_debug_text_filter", null) - ?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } - ?: emptyList() - } // ── Logs directory ─────────────────────────────────────────────── @@ -147,22 +196,25 @@ object LogManager { } private fun resolveLogsDir(context: Context, prefs: SharedPreferences): File { - val pathString = resolvePathString(prefs.getString("winlator_path_uri", null), context) - val dir = File(pathString, "logs") + val currentPath = resolvePathString( + prefs.getString("winlator_path_uri", null), SettingsConfig.DEFAULT_WINLATOR_PATH, context, + ) - Timber.d("Winnative pathString: $pathString") + Timber.d("Winnative pathString: $currentPath") + val dir = File(currentPath, "logs") if (!dir.exists()) dir.mkdirs() return dir } - private fun resolvePathString(uriStr: String?, context: Context): String { - if (uriStr.isNullOrEmpty()) return SettingsConfig.DEFAULT_WINLATOR_PATH + private fun resolvePathString(uriStr: String?, fallback: String, ctx: Context): String { + if (uriStr.isNullOrEmpty()) return fallback return try { - FileUtils.getFilePathFromUri(context, Uri.parse(uriStr)) ?: SettingsConfig.DEFAULT_WINLATOR_PATH + val uri = Uri.parse(uriStr) + FileUtils.getFilePathFromUri(ctx, uri) ?: uriStr } catch (e: Exception) { Timber.tag(TAG).w("Failed to resolve winlator_path_uri ($uriStr): ${e.message}") - SettingsConfig.DEFAULT_WINLATOR_PATH + uriStr } } @@ -174,7 +226,7 @@ object LogManager { prefs.getBoolean("enable_steam_logs", false) || prefs.getBoolean("enable_input_logs", false) || prefs.getBoolean("enable_download_logs", false) || - isDebugEnabledCached + cachedAppDebugEnabled } fun updateLoggingState(context: Context) { @@ -199,6 +251,69 @@ object LogManager { logsDir.listFiles()?.filter { it.name.endsWith(".log") }?.forEach { it.delete() } } + // ── Tag management (settings UI surface) ────────────────────────── + + /** Union of build-time-discovered tags and user-added custom ones, sorted for display. */ + @JvmStatic + fun getAllKnownTags(): List = + (GeneratedLogTags.TAGS + cachedCustomTags).distinct().sorted() + + @JvmStatic + fun addCustomTag(context: Context, tag: String) { + val cleaned = tag.trim() + if (cleaned.isEmpty()) return + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val updated = cachedCustomTags + cleaned + prefs.edit().putString(PREF_CUSTOM_TAGS, updated.joinToString(",")).apply() + } + + @JvmStatic + fun removeCustomTag(context: Context, tag: String) { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val updatedCustomTags = cachedCustomTags - tag + val updatedSelectedTags = cachedSelectedTags - tag // deselect too — a removed tag can't stay selected + prefs.edit() + .putString(PREF_CUSTOM_TAGS, updatedCustomTags.joinToString(",")) + .putString(PREF_SELECTED_TAGS, updatedSelectedTags.joinToString(",")) + .apply() + } + + @JvmStatic + fun setSelectedTags(context: Context, tags: Set) { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(PREF_SELECTED_TAGS, tags.joinToString(",")).apply() + } + + @JvmStatic + fun getSelectedTags(): Set = cachedSelectedTags + + @JvmStatic + fun setTagFilterMode(context: Context, mode: TagFilterMode) { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(PREF_TAG_FILTER_MODE, mode.name).apply() + } + + @JvmStatic + fun getTagFilterMode(): TagFilterMode = cachedTagFilterMode + + /** Transient only — never written to SharedPreferences. Pass null/blank to clear. */ + @JvmStatic + fun setManualTextFilter(text: String?) { + manualTextFilter = text?.trim()?.takeIf { it.isNotEmpty() } + } + + @JvmStatic + fun getManualTextFilter(): String = manualTextFilter ?: "" + + @JvmStatic + fun clearManualTextFilter() = setManualTextFilter(null) + + private fun passesTagFilter(tag: String): Boolean = when (cachedTagFilterMode) { + TagFilterMode.ALL -> true + TagFilterMode.INCLUDE -> tag in cachedSelectedTags + TagFilterMode.EXCLUDE -> tag !in cachedSelectedTags + } + // ── Wine/Box64 Logcat Capture ──────────────────────────────────── fun startLogging(context: Context) { @@ -242,7 +357,7 @@ object LogManager { @JvmStatic fun startAppLogging(context: Context) { - if (!isDebugEnabledCached) return + if (!cachedAppDebugEnabled) return val logFile = File(getLogsDir(context), "application.log") try { @@ -351,22 +466,22 @@ object LogManager { * instead. */ inline fun log(tag: String, context: Context? = null, message: () -> String) { - if (!isDebugEnabledCached) return + if (!cachedAppDebugEnabled) return baseLog(Level.DEBUG, tag, message(), null, context) } inline fun logI(tag: String, context: Context? = null, message: () -> String) { - if (!isDebugEnabledCached) return + if (!cachedAppDebugEnabled) return baseLog(Level.INFO, tag, message(), null, context) } inline fun logW(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) { - if (!isDebugEnabledCached) return + if (!cachedAppDebugEnabled) return baseLog(Level.WARN, tag, message(), null, context) } inline fun logE(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) { - if (!isDebugEnabledCached) return + if (!cachedAppDebugEnabled) return baseLog(Level.ERROR, tag, message(), null, context) } @@ -379,21 +494,20 @@ object LogManager { Level.ERROR -> if (t != null) Timber.tag(tag).e(t, message) else Timber.tag(tag).e(message) } - if (!isDebugEnabledCached) return - if (cachedAllowedTags.isNotEmpty() && tag !in cachedAllowedTags) return - if (cachedTextFilters.isNotEmpty() && cachedTextFilters.none { message.contains(it, ignoreCase = true) }) return + if (!cachedAppDebugEnabled) return + if (!passesTagFilter(tag)) return + manualTextFilter?.let { if (!message.contains(it, ignoreCase = true)) return } val ctx = resolveContext(context) ?: return val fullMessage = if (t != null) "$message :: ${Log.getStackTraceString(t)}" else message - appendLine(ctx, LOG_FILE, "${level.prefix}/$tag", fullMessage) + appendLine(ctx, APP_LOG_FILE, "${level.prefix}/$tag", fullMessage) } private fun appendLine(context: Context, fileName: String, level: String, message: String) { try { - val file = File(getLogsDir(context), fileName) - file.appendText("${timestampFormat.format(Date())} $level: $message\n") + File(getLogsDir(context), fileName).appendText("${timestampFormat.format(Date())} $level: $message\n") } catch (e: Exception) { - Timber.e("Failed to append to $fileName: ${e.message}") + Timber.tag(TAG).e("Failed to append to $fileName: ${e.message}") } } @@ -409,8 +523,8 @@ object LogManager { // " messages, which is the signal of the OS killing a process. @JvmStatic - fun startPauseWatch(context: Context) { - if (!isDebugEnabledCached) return + fun startEventWatch(context: Context, label: String = "watcher") { + if (!cachedEventWatchEnabled) return // Verify READ_LOGS permission at runtime if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_LOGS) @@ -418,41 +532,74 @@ object LogManager { logW(TAG, null, context) { "READ_LOGS permission not granted, pause watch may not capture system logs" } } - stopPauseWatch() + stopEventWatch() try { // Wipe the historical buffer so this file only contains lines from // this pause window onward — not hours of unrelated backlog. Runtime.getRuntime().exec(arrayOf("logcat", "-c")).waitFor() + val safeLabel = label.ifBlank { "manual" }.replace(Regex("[^A-Za-z0-9_-]"), "_") val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val file = File(getLogsDir(context), "pause_$stamp.log") - appendLine(context, file.name, "I/$TAG", "=== pause window started ===") - pauseWatchProcess = Runtime.getRuntime().exec( + val file = File(getLogsDir(context), "event_${safeLabel}_$stamp.log") + appendLine(context, file.name, "I/$TAG", "=== event watch started ($safeLabel) ===") + + val command = mutableListOf("logcat", "-v", "threadtime", "-f", file.absolutePath) + command.addAll(buildLogcatFilterSpecArgs()) + manualTextFilter?.let { + command.add("-e") + command.add(it) + } + + // New with custom logcat filter + eventWatchProcess = Runtime.getRuntime().exec(command.toTypedArray()) + + // Old, with hardcoded filter + /*eventWatchProcess = Runtime.getRuntime().exec( arrayOf( "logcat", "-v", "threadtime", "-f", file.absolutePath, - "ActivityManager:I", "lmkd:I", "OomAdjuster:I", "ActivityTaskManager:I", "Process:I", -// "*:S", // Silence - "*:D", // Debug [Note: This filter drastically increases the chances that the container will close upon returning to it] +// "ActivityManager:I", "lmkd:I", "OomAdjuster:I", "ActivityTaskManager:I", "Process:I", // For tracking OS killer. + // For tracking the annoying container-resume shut down bug. + "ActivityManager:I", "Process:I", "XConnectorEpoll:D", "ClientSocket:D", "XClientConnectionHandler:D", "Surface:I", "VkRenderer:I", + "*:S", // Silence +// "*:D", // Debug [Note: This filter drastically increases the chances that the container will close upon returning to it] ), - ) - closeProcessStdin(pauseWatchProcess) + )*/ + + closeProcessStdin(eventWatchProcess) } catch (e: Exception) { -// Timber.e("Failed to start pause watch: ${e.message}") - logE(TAG, null, context) { "Failed to start pause watch: ${e.message}" } +// Timber.tag(TAG).e("Failed to start event watch: ${e.message}") + logE(TAG, null, context) { "Failed to start event watch: ${e.message}" } } } @JvmStatic - fun stopPauseWatch() { + fun stopEventWatch() { try { - pauseWatchProcess?.let(::destroyProcess) - pauseWatchProcess = null + eventWatchProcess?.let(::destroyProcess) + eventWatchProcess = null } catch (e: Exception) { Timber.e("Failed to stop pause watch: ${e.message}") } } - // ── 3. Why was the process last killed? ────────────────────────── + private fun buildLogcatFilterSpecArgs(): List { + val spec = mutableListOf() + spec.addAll(BASELINE_SYSTEM_TAGS) + when (cachedTagFilterMode) { + TagFilterMode.ALL -> spec.add("*:D") + TagFilterMode.EXCLUDE -> { + spec.add("*:D") + cachedSelectedTags.forEach { spec.add("$it:S") } + } + TagFilterMode.INCLUDE -> { + cachedSelectedTags.forEach { spec.add("$it:D") } + spec.add("*:S") + } + } + return spec + } + + // ── 3. Exit/killed reasons | crash trace ────────────────────────── // // No special permission needed (API 30+). Call once, early, on // every app start — it tells you, after the fact, exactly what @@ -462,40 +609,52 @@ object LogManager { @JvmStatic @JvmOverloads fun logLastExitReasons(context: Context? = null) { - if (!isDebugEnabledCached) return + if (!cachedExitReasonLogEnabled && !cachedCrashLogEnabled) return val ctx = resolveContext(context) ?: return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + val maxExitReasons = 5 + try { val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - val infos: List = - am.getHistoricalProcessExitReasons(ctx.packageName, 0, 5) + val infos: List = am.getHistoricalProcessExitReasons(ctx.packageName, 0, maxExitReasons) if (infos.isEmpty()) { - appendLine(ctx, "exit_reasons.log", "I/$TAG", "No historical exit info available") + appendLine(ctx, EXIT_REASONS_FILE, "I/$TAG", "No historical exit info available") return } - for (info in infos) { - appendLine( - ctx, "exit_reasons.log", "I/$TAG", - "pid=${info.pid} reason=${info.reason} importance=${info.importance} " + - "desc=${info.description} timestamp=${Date(info.timestamp)}", - ) - if (info.reason == ApplicationExitInfo.REASON_CRASH_NATIVE) { + for ((index, info) in infos.withIndex()) { + if (cachedExitReasonLogEnabled) { + // Separator line with reason number: 0 = newest/last, larger = older + appendLine( + ctx, EXIT_REASONS_FILE, "I/$TAG", + "---- Exit reason #${index} (0=new/last, ${maxExitReasons}=oldest) ----" + ) + + appendLine( + ctx, EXIT_REASONS_FILE, "I/$TAG", + "pid=${info.pid} reason=${info.reason} importance=${info.importance} " + + "desc=${info.description} timestamp=${Date(info.timestamp)}", + ) + } + if (cachedCrashLogEnabled && info.reason == ApplicationExitInfo.REASON_CRASH_NATIVE) { try { info.traceInputStream?.use { input -> - appendLine(ctx, "exit_reasons.log", "I/$TAG", "Tombstone Data:\n${input.bufferedReader().readText()}") + appendLine( + ctx, CRASH_FILE, "I/$TAG", + "Tombstone pid=${info.pid} timestamp=${Date(info.timestamp)}:\n${input.bufferedReader().readText()}", + ) } } catch (e: Exception) { - Timber.e("Failed to read tombstone trace: ${e.message}") + Timber.tag(TAG).e("Failed to read tombstone trace: ${e.message}") } } } } catch (e: Exception) { - Timber.e("Failed to read exit reasons: ${e.message}") + Timber.tag(TAG).e("Failed to read exit reasons: ${e.message}") } } - @JvmStatic + /*@JvmStatic fun logCrash(context: Context, thread: Thread, throwable: Throwable) { try { val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date()) @@ -518,5 +677,5 @@ object LogManager { } catch (e: Exception) { Timber.e(e, "Failed to log crash") } - } + }*/ } diff --git a/gradle/collectLogTags.gradle b/gradle/collectLogTags.gradle new file mode 100644 index 000000000..dc6ffc70c --- /dev/null +++ b/gradle/collectLogTags.gradle @@ -0,0 +1,104 @@ +// gradle/collectLogTags.gradle +// Allow overriding the directories to scan with a project property: +// -PcollectLogTags.srcDirs=src/main/app,src/main/runtime +// If not provided, use the project's conventional dirs but explicitly ignore src/main/java. +def defaultCandidates = [ + 'src/main/app', + 'src/main/feature', + 'src/main/sharedmemory', + 'src/main/runtime', + 'src/main/shared' +] + +def javaDir = file('src/main/java') + +def srcDirs = [] +if (project.hasProperty('collectLogTags.srcDirs')) { + srcDirs = project.property('collectLogTags.srcDirs').toString().split(',').collect { file(it.trim()) } +} else { + srcDirs = defaultCandidates.collect { file(it) } +} + +// Remove any non-existent directories and explicitly exclude the java src dir to avoid duplicates +srcDirs = srcDirs.findAll { it.exists() && !(javaDir.exists() && it.canonicalFile == javaDir.canonicalFile) } + +def outputFile = file('build/generated/source/logtags/com/winlator/cmod/runtime/system/GeneratedLogTags.kt') +def manualOutputFile = file('src/main/java/com/winlator/cmod/runtime/system/GeneratedLogTags.kt') + +tasks.register('collectLogTags') { + srcDirs.each { dir -> + inputs.files(fileTree(dir).include('**/*.kt', '**/*.java')) + .withPropertyName("srcDir_${dir.name}_${dir.path.hashCode()}") + .withPathSensitivity(PathSensitivity.RELATIVE) + .optional() + } + // Only declare the generated output if we're actually going to write it. If a manual + // `GeneratedLogTags.kt` exists in `src/main/java` then by default we skip generation to + // avoid duplicate-class compilation errors. Set -PcollectLogTags.overwrite=true to force + // generation (and overwrite the manual file if you want to replace it). + def shouldGenerate = !manualOutputFile.exists() || project.hasProperty('collectLogTags.overwrite') && project.property('collectLogTags.overwrite').toString().toLowerCase() == 'true' + if (shouldGenerate) { + outputs.file outputFile + } else { + println "collectLogTags: manual GeneratedLogTags.kt exists at ${manualOutputFile}; skipping generation (set -PcollectLogTags.overwrite=true to force)." + } + + doLast { + def tags = new TreeSet() + def kotlinTagDecl = ~/((?:private\s+)?(?:const\s+)?val\s+TAG\s*=\s*")([^"]+)/ + def javaTagDecl = ~/private\s+static\s+final\s+String\s+TAG\s*=\s*"([^"]+)"\s*;/ + def literalCallSite = /LogManager\.(?:log|logI|logW|logE)\(\s*"([^"]+)"/ + + inputs.files.each { f -> + if (!f.isFile()) return + + // Skip any files that live under src/main/java to avoid scanning the project's primary java + // source dir (this prevents duplicates when you keep a hand-written GeneratedLogTags.kt there). + try { + if (javaDir.exists() && f.canonicalFile.path.startsWith(javaDir.canonicalFile.path)) return + } catch (ignored) { + // ignore canonicalization failures and continue + } + + def text = f.text + if (!text.contains('LogManager.')) return + + def literalMatch = (text =~ literalCallSite) + def literalTag = literalMatch ? (literalMatch[0][1]) : null + + def declaredTag = null + def kotlinMatch = (text =~ kotlinTagDecl) + if (kotlinMatch && kotlinMatch.size() > 0) { + declaredTag = kotlinMatch[0][2] + } else { + def javaMatch = (text =~ javaTagDecl) + if (javaMatch && javaMatch.size() > 0) { + declaredTag = javaMatch[0][1] + } + } + + def resolved = literalTag ?: declaredTag ?: f.name.replaceFirst(/\.[^.]+$/, '') + tags.add(resolved) + } + + // If a manual file exists and overwrite wasn't requested, do nothing. + if (manualOutputFile.exists() && !(project.hasProperty('collectLogTags.overwrite') && project.property('collectLogTags.overwrite').toString().toLowerCase() == 'true')) { + println "collectLogTags: detected manual file ${manualOutputFile}; generation skipped." + return + } + + outputFile.parentFile.mkdirs() + outputFile.withWriter('UTF-8') { w -> + w.println 'package com.winlator.cmod.runtime.system' + w.println() + w.println '/** Auto-generated by the collectLogTags Gradle task. Do not edit by hand. */' + w.println 'object GeneratedLogTags {' + w.println ' val TAGS: Set = setOf(' + tags.each { t -> w.println " \"${t}\"," } + w.println ' )' + w.println '}' + } + + println "collectLogTags: wrote ${outputFile} with ${tags.size()} tags" + } +} From ffd1d3fe98495d37dc22441d9f1d08f95b7c0929 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Wed, 1 Jul 2026 10:18:28 +0200 Subject: [PATCH 04/19] Add translations for the new debug options. - Added translations for the following languages: - Spanish - German - French - Italian --- app/src/main/res/values-de/strings.xml | 17 +++++++++++++++++ app/src/main/res/values-es/strings.xml | 18 ++++++++++++++++++ app/src/main/res/values-fr/strings.xml | 18 ++++++++++++++++++ app/src/main/res/values-it/strings.xml | 18 ++++++++++++++++++ 4 files changed, 71 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fc3317f22..e0865850d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1454,6 +1454,23 @@ Installierter Pfad: Benutzerdefinierter Wert Zum Zielen neigen (Ausrichtung) + + Grund-für-Beendigung-Protokoll + Erfasst, warum der App-Prozess beendet wurde + Absturzprotokoll + Native Crash-Tombstone-Spuren speichern + Ereignisüberwachungsprotokoll + Fokussiertes Systemprotokoll um Pause/Fortsetzen-Ereignisse erfassen + Protokoll-Tag-Filter + Tags ausgewählt + Protokollzeilen nach Text filtern… + Modus + Alle + Einschließen + Ausschließen + Benutzerdefinierten Tag hinzufügen… + Keine Tags ausgewählt + Dateien Kopieren diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 47b9a0279..a3096e06a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1453,6 +1453,24 @@ Ruta instalada: Traer al frente Valor personalizado Inclinar para apuntar (orientación) + + + Registro de razón de salida + Registra por qué terminó el proceso de la aplicación + Registro de fallos + Guardar registros del último crash + Registro de observación de eventos + Capturar un registro del sistema centrado alrededor de eventos de pausa/reanudación + Filtro de etiqueta de registro + etiquetas seleccionadas + Filtrar líneas de registro por texto… + Modo + Todas + Incluir + Excluir + Añadir etiqueta personalizada… + Ninguna etiqueta seleccionada + Archivos Copiar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1437450d8..1e289bedc 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1453,6 +1453,24 @@ Chemin installé : Mettre au premier plan Valeur personnalisée Incliner pour viser (orientation) + + + Journal des raisons de sortie + Enregistre pourquoi le processus de l\'application s\'est terminé + Journal des plantages + Enregistrer les traces de tombstones de plantages natifs + Journal de suivi des événements + Capturer un journal système centré autour des événements de pause/reprise + Filtre d\'étiquettes de journal + étiquettes sélectionnées + Filtrer les lignes du journal par texte… + Mode + Tous + Inclure + Exclure + Ajouter une étiquette personnalisée… + Aucune étiquette sélectionnée + Fichiers Copier diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3e97851c0..28f31efe2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1453,6 +1453,24 @@ Percorso installato: Porta in primo piano Valore personalizzato Inclina per mirare (orientamento) + + + Registro motivo uscita + Registra il motivo della terminazione del processo dell\'app + Registro errori + Salva tracce tombstone di arresti anomali nativi + Registro monitoraggio eventi + Cattura un registro di sistema focalizzato attorno agli eventi di pausa/ripresa + Filtro tag registro + tag selezionati + Filtra righe registro per testo… + Modalità + Tutti + Includi + Escludi + Aggiungi tag personalizzato… + Nessun tag selezionato + File Copia From cb50dfeefb8c1bf6ae27c1c9a34a67a1833403ed Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Wed, 1 Jul 2026 10:25:36 +0200 Subject: [PATCH 05/19] Add more logs to several classes. - Remove redundant line in SessionKeepAliveService - onTimeout() method. - Updated XServerDisplayActivity with the refactored new LogManager and added some more logs. Added more logs to: - VulkanRenderer - XConnectorEpoll - Added a `reason` parameter to `killConnection` method for get a more detailed description of what is happening and why. - XServerSurfaceView --- .../display/XServerDisplayActivity.java | 68 +++++++++++++++++-- .../display/connector/XConnectorEpoll.java | 47 +++++++++++-- .../display/renderer/VulkanRenderer.java | 9 +++ .../display/ui/XServerSurfaceView.java | 16 +++++ .../system/SessionKeepAliveService.java | 1 - 5 files changed, 129 insertions(+), 12 deletions(-) diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 3280a2bf4..0f82a87a5 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -174,6 +174,7 @@ import java.util.Locale; import java.util.Timer; import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicLong; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.concurrent.CountDownLatch; @@ -447,6 +448,7 @@ public boolean isInputSuspended() { private boolean isDarkMode; private boolean enableLogsMenu; private static final String TAG = "XServerDisplayActivity"; + private static final AtomicLong breadcrumbCounter = new AtomicLong(0); private GuestProgramLauncherComponent guestProgramLauncherComponent; private EnvVars overrideEnvVars; @@ -2319,8 +2321,63 @@ public void onResume() { boolean cleaningUp = exitRequested.get() || sessionCleanupStarted.get() || activityDestroyed.get(); if (!cleaningUp && environment != null) { - xServerView.onResume(); - environment.onResume(); + /*xServerView.onResume(); + environment.onResume();*/ + long breadcrumbId = breadcrumbCounter.incrementAndGet(); + + // xServerView.onResume() breadcrumb before + long tBeforeSurfaceNano = System.nanoTime(); + long tBeforeSurfaceMs = System.currentTimeMillis(); + LogManager.logI(TAG, + "breadcrumbId=" + breadcrumbId + " surface resume starts before xServerView.onResume" + + " tsNano=" + tBeforeSurfaceNano + " tsMs=" + tBeforeSurfaceMs + + " thread=" + Thread.currentThread().getName(), + this); + + try { + xServerView.onResume(); + } catch (Throwable t) { + LogManager.logW(TAG, + "breadcrumbId=" + breadcrumbId + " exception during xServerView.onResume", + t, this); + throw t; + } + + long tAfterSurfaceNano = System.nanoTime(); + long tAfterSurfaceMs = System.currentTimeMillis(); + LogManager.logI(TAG, + "breadcrumbId=" + breadcrumbId + " surface resume completed after xServerView.onResume" + + " tsNano=" + tAfterSurfaceNano + " tsMs=" + tAfterSurfaceMs + + " elapsedNs=" + (tAfterSurfaceNano - tBeforeSurfaceNano) + + " thread=" + Thread.currentThread().getName(), + this); + + // environment.onResume() breadcrumb before + long tBeforeEnvNano = System.nanoTime(); + long tBeforeEnvMs = System.currentTimeMillis(); + LogManager.logI(TAG, + "breadcrumbId=" + breadcrumbId + " environment resume starts before environment.onResume" + + " tsNano=" + tBeforeEnvNano + " tsMs=" + tBeforeEnvMs + + " thread=" + Thread.currentThread().getName(), + this); + + try { + environment.onResume(); + } catch (Throwable t) { + LogManager.logW(TAG, + "breadcrumbId=" + breadcrumbId + " exception during environment.onResume", + t, this); + throw t; + } + + long tAfterEnvNano = System.nanoTime(); + long tAfterEnvMs = System.currentTimeMillis(); + LogManager.logI(TAG, + "breadcrumbId=" + breadcrumbId + " environment resume completed after environment.onResume" + + " tsNano=" + tAfterEnvNano + " tsMs=" + tAfterEnvMs + + " elapsedNs=" + (tAfterEnvNano - tBeforeEnvNano) + + " thread=" + Thread.currentThread().getName(), + this); } if (inputControlsView != null && touchpadView != null) { @@ -2347,12 +2404,12 @@ public void onResume() { SessionKeepAliveService.onResumeSession(this); LogManager.log(TAG, "Session resumed", getApplicationContext()); - handler.postDelayed(LogManager::stopPauseWatch, 5000); + handler.postDelayed(LogManager::stopEventWatch, 5000); } @Override public void onPause() { - LogManager.startPauseWatch(getApplicationContext()); + LogManager.startEventWatch(getApplicationContext(), "onPause"); LogManager.log(TAG, "Session paused; entering background", getApplicationContext()); SessionKeepAliveService.onPauseSession(this); @@ -3729,7 +3786,7 @@ protected void onDestroy() { if (exitRequested.get()) { SessionKeepAliveService.stopSession(this); } - LogManager.stopPauseWatch(); + LogManager.stopEventWatch(); super.onDestroy(); if (!switchLaunchInProgress.get()) { @@ -6368,6 +6425,7 @@ private static boolean safeEquals(String a, String b) { return a != null && a.equals(b); } + // ToDo: Test this disabled as suspect of being related to container auto shut down. private void setupXEnvironment() throws PackageManager.NameNotFoundException { if (SessionKeepAliveService.isSessionActive()) { XEnvironment existingEnv = SessionKeepAliveService.getActiveEnvironment(); diff --git a/app/src/main/runtime/display/connector/XConnectorEpoll.java b/app/src/main/runtime/display/connector/XConnectorEpoll.java index 451a5a400..5975f63fa 100644 --- a/app/src/main/runtime/display/connector/XConnectorEpoll.java +++ b/app/src/main/runtime/display/connector/XConnectorEpoll.java @@ -2,6 +2,9 @@ import android.util.SparseArray; import androidx.annotation.Keep; + +import com.winlator.cmod.runtime.system.LogManager; + import java.io.IOException; import java.nio.ByteBuffer; @@ -19,6 +22,8 @@ public class XConnectorEpoll implements Runnable { private int initialOutputBufferCapacity = 4096; private final SparseArray connectedClients = new SparseArray<>(); + private String TAG = "XConnectorEpoll"; + static { System.loadLibrary("winlator"); } @@ -123,10 +128,10 @@ private void handleExistingConnection(int fd) { while (running && requestHandler.handleRequest(client)) activePosition = inputStream.getActivePosition(); inputStream.setActivePosition(activePosition); - } else killConnection(client); + } else killConnection(client, "EOF on read"); // handleExistingConnection's readMoreData()<=0 branch } else requestHandler.handleRequest(client); } catch (IOException e) { - killConnection(client); + killConnection(client, "IOException: " + e); } } @@ -136,7 +141,17 @@ public Client getClient(int fd) { } } - public void killConnection(Client client) { + public void killConnection(Client client, String reason) { + if (client == null) { + LogManager.logW(TAG, "killConnection called with null client; reason=" + reason); + return; + } + + // Log immediately on entry: fd, reason, whether multithreadedClients + int fd = client.clientSocket != null ? client.clientSocket.fd : -1; + LogManager.logI(TAG, + "killConnection entry: fd=" + fd + " reason=\"" + reason + "\" multithreadedClients=" + multithreadedClients); + if (!client.markKillingOnce()) return; // already being/been torn down by another thread client.connected = false; connectionHandler.handleConnectionShutdown(client); @@ -148,6 +163,11 @@ public void killConnection(Client client) { try { client.pollThread.join(); } catch (InterruptedException e) { + // Restore interrupt status and log interruption + Thread.currentThread().interrupt(); + LogManager.logW(TAG, + "Interrupted while joining pollThread for fd=" + fd + " reason=\"" + reason + "\""); + break; } } @@ -156,12 +176,27 @@ public void killConnection(Client client) { closeFd(client.shutdownFd); } else removeFdFromEpoll(epollFd, client.clientSocket.fd); // Free direct byte buffers and ancillary FDs to avoid resource leak warnings - client.releaseIOStreams(); - client.clientSocket.closeAncillaryFds(); + try { + client.releaseIOStreams(); + } catch (Exception e) { + LogManager.logW(TAG, + "Exception releasing IO streams for fd=" + fd + " reason=\"" + reason + "\"", + e); + } + try { + client.clientSocket.closeAncillaryFds(); + } catch (Exception e) { + LogManager.logW(TAG, + "Exception closing ancillary FDs for fd=" + fd + " reason=\"" + reason + "\"", + e); + } closeFd(client.clientSocket.fd); synchronized (connectedClients) { connectedClients.remove(client.clientSocket.fd); } + + LogManager.log(TAG, + "killConnection completed: fd=" + fd + " reason=\"" + reason + "\""); } private void shutdown() { @@ -171,7 +206,7 @@ private void shutdown() { if (connectedClients.size() == 0) break; client = connectedClients.valueAt(connectedClients.size() - 1); } - killConnection(client); + killConnection(client, "connector shutdown"); } removeFdFromEpoll(epollFd, serverFd); diff --git a/app/src/main/runtime/display/renderer/VulkanRenderer.java b/app/src/main/runtime/display/renderer/VulkanRenderer.java index d7e692b85..3de4bb1a1 100644 --- a/app/src/main/runtime/display/renderer/VulkanRenderer.java +++ b/app/src/main/runtime/display/renderer/VulkanRenderer.java @@ -23,6 +23,7 @@ import com.winlator.cmod.runtime.display.xserver.WindowManager; import com.winlator.cmod.runtime.display.xserver.XLock; import com.winlator.cmod.runtime.display.xserver.XServer; +import com.winlator.cmod.runtime.system.LogManager; import com.winlator.cmod.shared.math.Mathf; import com.winlator.cmod.shared.math.XForm; import java.nio.ByteBuffer; @@ -216,9 +217,12 @@ public void attachSurface(Surface surface) { destroyed.set(false); xServer.windowManager.addOnWindowModificationListener(this); xServer.pointer.addOnPointerMotionListener(this); + LogManager.log(TAG, "attachSurface: nativeHandle == 0: true"); } nativeSurfaceCreated(nativeHandle, surface); + LogManager.log(TAG, "attachSurface: inside [synchronized (this)]"); } + LogManager.log(TAG, "attachSurface called"); } private boolean shouldEnableValidationLayers() { @@ -240,8 +244,10 @@ public void notifySurfaceChanged(int w, int h) { public void detachSurface() { // Same monitor as destroy()/attachSurface; re-check the handle under the lock. synchronized (this) { + LogManager.log(TAG, "detachSurface: nativeHandle != 0: " + (nativeHandle != 0)); if (nativeHandle != 0) nativeSurfaceDestroyed(nativeHandle); } + LogManager.log(TAG, "detachSurface called"); } /** Start mirroring the composited output into {@code encoderSurface}; false if the native setup failed. */ @@ -289,6 +295,7 @@ public int getRecordOrientationHint() { @Override public void onSurfaceCreated() { // Surface is already attached in attachSurface(). Nothing else to do here. + LogManager.log(TAG, "onSurfaceCreated called"); } @Override @@ -298,10 +305,12 @@ public void onSurfaceChanged(int width, int height) { viewTransformation.update(width, height, xServer.screenInfo.width, xServer.screenInfo.height); viewportNeedsUpdate = true; + LogManager.log(TAG, "onSurfaceChanged called: " + width + "x" + height); } @Override public void onSurfaceDestroyed() { + LogManager.log(TAG, "onSurfaceDestroyed called"); destroy(); } diff --git a/app/src/main/runtime/display/ui/XServerSurfaceView.java b/app/src/main/runtime/display/ui/XServerSurfaceView.java index c1e00a40d..76ba39ec8 100644 --- a/app/src/main/runtime/display/ui/XServerSurfaceView.java +++ b/app/src/main/runtime/display/ui/XServerSurfaceView.java @@ -2,6 +2,7 @@ import android.annotation.SuppressLint; import android.content.Context; +import android.util.Log; import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.ViewGroup; @@ -9,6 +10,8 @@ import com.winlator.cmod.runtime.display.renderer.RenderCallback; import com.winlator.cmod.runtime.display.renderer.VulkanRenderer; import com.winlator.cmod.runtime.display.xserver.XServer; +import com.winlator.cmod.runtime.system.LogManager; + import java.util.ArrayDeque; import java.util.Deque; @@ -46,6 +49,8 @@ public class XServerSurfaceView extends SurfaceView implements SurfaceHolder.Cal private volatile int width; private volatile int height; + private String TAG = "XServerSurfaceView"; + public XServerSurfaceView(Context context, XServer xServer) { super(context); setLayoutParams(new FrameLayout.LayoutParams( @@ -103,14 +108,18 @@ public void onResume() { paused = false; renderRequested = true; renderLock.notifyAll(); + LogManager.log(TAG, "onResume: inside [synchronized (renderLock)]"); } + LogManager.log(TAG, "onResume called"); } public void onPause() { synchronized (renderLock) { paused = true; renderLock.notifyAll(); + LogManager.log(TAG, "onPause: inside [synchronized (renderLock)]"); } + LogManager.log(TAG, "onPause called"); } // --- SurfaceHolder.Callback ---------------------------------------------- @@ -123,9 +132,11 @@ public void surfaceCreated(SurfaceHolder holder) { surfaceReady = false; width = 0; height = 0; + LogManager.log(TAG, "surfaceCreated: inside [synchronized (renderLock)]"); } renderer.attachSurface(holder.getSurface()); startRenderThreadIfNeeded(); + LogManager.log(TAG, "surfaceCreated called"); } private void joinRetiringRenderThread() { @@ -147,6 +158,7 @@ public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) { width = 0; height = 0; renderLock.notifyAll(); + LogManager.log(TAG, "surfaceChanged: inside [synchronized (renderLock)] return"); } return; } @@ -159,7 +171,9 @@ public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) { surfaceReady = true; renderRequested = true; renderLock.notifyAll(); + LogManager.log(TAG, "surfaceChanged: inside [synchronized (renderLock)] second"); } + LogManager.log(TAG, "surfaceChanged called"); } @Override @@ -169,10 +183,12 @@ public void surfaceDestroyed(SurfaceHolder holder) { width = 0; height = 0; renderLock.notifyAll(); + LogManager.log(TAG, "surfaceDestroyed: inside [synchronized (renderLock)]"); } // Run the render thread one more iteration so it sees surfaceReady=false and exits. stopRenderThread(); renderer.detachSurface(); + LogManager.log(TAG, "surfaceDestroyed called"); } // --- Render thread ------------------------------------------------------- diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index a40e8f21b..2ed06c6ab 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -430,7 +430,6 @@ public void onTimeout(int startId, int fstype) { // stopProtectionHeartbeat(); stopForegroundCompat(); stopSelf(); - super.onTimeout(startId, fstype); } private static void sendCommand(Context ctx, String action, @Nullable String tag) { From d5b422d30c49433ccd997785f1554aa15766492e Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 4 Jul 2026 19:35:52 +0200 Subject: [PATCH 06/19] Enhance background protection, improve crash logging and fixed some bugs/crashes This update introduces a configurable background protection system designed to improve session stability when the app is in the background. It also enhances diagnostic capabilities by providing more detailed crash and exit reason reporting. Key changes include: - Background Protection: Added an optional heartbeat mechanism and optional CPU `WAKE_LOCK` to prevent aggressive OOM killing by the OS or avoid issues related to the phone deep sleep mode. - Process Management: Introduced `BackgroundPauseMode` (All, Game Only, or All except Game), allowing users to customize which processes are suspended when the session is paused. - Improved Logging: Updated `LogManager` to capture detailed `ApplicationExitInfo`, including traces for ANRs, Java crashes, and native crashes. - Service Enhancements: Integrated a new wake lock and heartbeat logic into `SessionKeepAliveService` to ensure persistent protection during background states. - UI Other Settings: Added auto pause, wake lock, heartbeat frequency and pause mode options so the testers can try different combinations for their devices. - UI Strings: Added new strings for background protection settings, including heartbeat frequency and auto-pause options. Other changes: - Fixed a crash that could occur during game startup, causing an infinite loop on the container initialization screen. - Improved some of the UI options by hiding locked settings that require other options to be enabled. Hidden and shown with a small animation. - Readded wake_lock permission to the AndroidManifest. --- app/src/main/AndroidManifest.xml | 3 +- .../feature/settings/debug/DebugFragment.kt | 12 + .../feature/settings/debug/DebugScreen.kt | 70 ++-- .../settings/other/OtherSettingsFragment.kt | 24 ++ .../settings/other/OtherSettingsScreen.kt | 306 +++++++++++++++++- .../stores/steam/service/SteamService.kt | 13 +- app/src/main/res/values/strings.xml | 18 ++ .../display/XServerDisplayActivity.java | 81 ++--- app/src/main/runtime/system/LogManager.kt | 109 ++++--- .../main/runtime/system/ProcessHelper.java | 112 ++++++- .../system/SessionKeepAliveService.java | 134 +++++++- 11 files changed, 720 insertions(+), 162 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 07ab6de98..e0db62ea9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,7 +34,8 @@ - + + diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index e4acdbd6a..bf4a0a91f 100644 --- a/app/src/main/feature/settings/debug/DebugFragment.kt +++ b/app/src/main/feature/settings/debug/DebugFragment.kt @@ -91,10 +91,22 @@ class DebugFragment : Fragment() { }, onExitReasonLogChanged = { checked -> preferences.edit { putBoolean("enable_exit_reason_log", checked) } + if (checked) { + // Capture previous exit reasons, so the user doesn't need + // to restart the app to check previous reasons. + com.winlator.cmod.runtime.system.LogManager + .logLastExitReasons(ctx) + } refresh() }, onCrashLogChanged = { checked -> preferences.edit { putBoolean("enable_crash_log", checked) } + if (checked) { + // Capture previous crashes, so the user doesn't need + // to restart to check previous reasons. + com.winlator.cmod.runtime.system.LogManager + .logLastExitReasons(ctx) + } refresh() }, onEventWatchLogChanged = { checked -> diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index c4651c8c4..51347d210 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -8,8 +8,10 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background @@ -310,37 +312,49 @@ fun DebugScreen( } item(key = "log_tag_filter_card") { - LogTagFilterCard( - mode = state.tagFilterMode, - selectedTags = state.selectedTags, - enabled = state.appDebug, - onEdit = { showTagFilterDialog = true }, - onModeChanged = onTagFilterModeChanged, - onRemoveSelectedTag = { tag -> - onSelectedTagsChanged(state.selectedTags - tag) - }, - ) + AnimatedVisibility( + visible = state.appDebug || state.eventWatchLog, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LogTagFilterCard( + mode = state.tagFilterMode, + selectedTags = state.selectedTags, + enabled = state.appDebug || state.eventWatchLog, + onEdit = { showTagFilterDialog = true }, + onModeChanged = onTagFilterModeChanged, + onRemoveSelectedTag = { tag -> + onSelectedTagsChanged(state.selectedTags - tag) + }, + ) + } } item(key = "manual_text_filter_field") { - var manualFilterText by remember { mutableStateOf(LogManager.getManualTextFilter()) } - val focusManager = LocalFocusManager.current - OutlinedTextField( - value = manualFilterText, - onValueChange = { - manualFilterText = it - onManualTextFilterChanged(it) - }, - placeholder = { Text(stringResource(R.string.settings_debug_manual_text_filter_hint)) }, - singleLine = true, - enabled = state.appDebug, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .focusProperties { canFocus = state.appDebug }, - ) + AnimatedVisibility( + visible = state.appDebug || state.eventWatchLog, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + var manualFilterText by remember { mutableStateOf(LogManager.getManualTextFilter()) } + val focusManager = LocalFocusManager.current + OutlinedTextField( + value = manualFilterText, + onValueChange = { + manualFilterText = it + onManualTextFilterChanged(it) + }, + placeholder = { Text(stringResource(R.string.settings_debug_manual_text_filter_hint)) }, + singleLine = true, + enabled = state.appDebug || state.eventWatchLog, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .focusProperties { canFocus = state.appDebug || state.eventWatchLog }, + ) + } } item(key = "emulation_section") { diff --git a/app/src/main/feature/settings/other/OtherSettingsFragment.kt b/app/src/main/feature/settings/other/OtherSettingsFragment.kt index d4f22571a..ff52d0c77 100644 --- a/app/src/main/feature/settings/other/OtherSettingsFragment.kt +++ b/app/src/main/feature/settings/other/OtherSettingsFragment.kt @@ -31,6 +31,7 @@ import com.winlator.cmod.feature.shortcuts.FrontendExporter import com.winlator.cmod.feature.setup.SetupWizardActivity import com.winlator.cmod.runtime.audio.midi.MidiManager import com.winlator.cmod.runtime.display.environment.ImageFsInstaller +import com.winlator.cmod.runtime.system.ProcessHelper import com.winlator.cmod.shared.ui.toast.WinToast import com.winlator.cmod.shared.android.DirectoryPickerDialog import com.winlator.cmod.shared.android.LocaleHelper @@ -170,6 +171,23 @@ class OtherSettingsFragment : Fragment() { preferences.edit { putBoolean("enable_background_session", checked) } refresh() }, + onEnableAutoPauseChanged = { checked -> + preferences.edit { putBoolean("enable_auto_pause_when_background", checked) } + refresh() + }, + onUseBackgroundWakelockChanged = { checked -> + preferences.edit { putBoolean("enable_background_wakelock", checked) } + refresh() + }, + onHeartbeatFrequencyChanged = { seconds -> + preferences.edit { putInt("background_heartbeat_frequency", seconds) } + refresh() + }, + onBackgroundPauseModeChanged = { mode -> + preferences.edit { putString("background_pause_mode", mode.prefValue) } + ProcessHelper.setBackgroundPauseMode(mode) + refresh() + }, onRunSetupWizard = { startActivity(SetupWizardActivity.createManualRerunIntent(ctx)) }, @@ -235,6 +253,12 @@ class OtherSettingsFragment : Fragment() { shareClipboard = preferences.getBoolean("share_android_clipboard", false), recordPerformanceToFile = preferences.getBoolean("hud_record_to_file", false), enableBackgroundSession = preferences.getBoolean("enable_background_session", false), + enableAutoPause = preferences.getBoolean("enable_auto_pause_when_background", false), + useBackgroundWakelock = preferences.getBoolean("enable_background_wakelock", false), + heartbeatFrequency = preferences.getInt("background_heartbeat_frequency", 0), + backgroundPauseMode = ProcessHelper.BackgroundPauseMode.fromPrefValue( + preferences.getString("background_pause_mode", ProcessHelper.BackgroundPauseMode.GAME_ONLY.prefValue) + ), imagefsInstallProgress = uiState.imagefsInstallProgress, ) } diff --git a/app/src/main/feature/settings/other/OtherSettingsScreen.kt b/app/src/main/feature/settings/other/OtherSettingsScreen.kt index bf6249065..ca764a468 100644 --- a/app/src/main/feature/settings/other/OtherSettingsScreen.kt +++ b/app/src/main/feature/settings/other/OtherSettingsScreen.kt @@ -3,12 +3,18 @@ /* Settings > Other screen — Jetpack Compose / Material3. * Uses a LazyColumn for the main content so the screen scrolls natively in Compose. */ package com.winlator.cmod.feature.settings +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.layout.Arrangement @@ -32,12 +38,16 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Autorenew +import androidx.compose.material.icons.outlined.BatteryAlert import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.KeyboardArrowUp import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.LibraryMusic import androidx.compose.material.icons.outlined.Mouse @@ -46,16 +56,20 @@ import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Speed import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.SystemUpdate +import androidx.compose.material.icons.outlined.Timer +import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderState 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 @@ -66,18 +80,23 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.winlator.cmod.R +import com.winlator.cmod.runtime.system.ProcessHelper import com.winlator.cmod.shared.ui.dialog.PopupDialog import com.winlator.cmod.shared.ui.outlinedSwitchColors @@ -92,6 +111,7 @@ private val TextPrimary = Color(0xFFF0F4FF) private val TextSecondary = Color(0xFF7A8FA8) private val SettingsSliderHeight = 24.dp private const val SettingsSliderTrackScaleY = 0.72f +private val Warning = Color(0xFFFF4444) // State data class OtherSettingsState( @@ -110,6 +130,10 @@ data class OtherSettingsState( val shareClipboard: Boolean = false, val recordPerformanceToFile: Boolean = false, val enableBackgroundSession: Boolean = false, + val enableAutoPause: Boolean = false, + val useBackgroundWakelock: Boolean = false, + val heartbeatFrequency: Int = 0, + val backgroundPauseMode: ProcessHelper.BackgroundPauseMode = ProcessHelper.BackgroundPauseMode.GAME_ONLY, val imagefsInstallProgress: Int? = null, ) @@ -153,6 +177,10 @@ fun OtherSettingsScreen( onShareClipboardChanged: (Boolean) -> Unit, onRecordPerformanceToFileChanged: (Boolean) -> Unit, onEnableBackgroundSessionChanged: (Boolean) -> Unit, + onEnableAutoPauseChanged: (Boolean) -> Unit, + onUseBackgroundWakelockChanged: (Boolean) -> Unit, + onHeartbeatFrequencyChanged: (Int) -> Unit, + onBackgroundPauseModeChanged: (ProcessHelper.BackgroundPauseMode) -> Unit, onRunSetupWizard: () -> Unit, onReinstallImagefs: () -> Unit, ) { @@ -281,8 +309,8 @@ fun OtherSettingsScreen( ) } - item(key = "integration_section") { - SectionLabel(stringResource(R.string.settings_other_section_integration), modifier = Modifier.padding(top = 8.dp)) + item(key = "background_section") { + SectionLabel(stringResource(R.string.settings_other_section_background), modifier = Modifier.padding(top = 8.dp)) } item(key = "background_session_card") { @@ -295,6 +323,56 @@ fun OtherSettingsScreen( ) } + item(key = "auto_pause_card") { + SettingsToggleCard( + title = stringResource(R.string.settings_other_bg_auto_pause_title), + subtitle = stringResource(R.string.settings_other_bg_auto_pause_subtitle), + icon = Icons.Outlined.Visibility, + checked = state.enableAutoPause, + onCheckedChange = onEnableAutoPauseChanged, + ) + } + + item(key = "background_wakelock_card") { + AnimatedVisibility( + visible = state.enableBackgroundSession, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + SettingsToggleCard( + title = stringResource(R.string.settings_other_bg_wakelock_title), + subtitle = stringResource(R.string.settings_other_bg_wakelock_subtitle), + icon = Icons.Outlined.BatteryAlert, + checked = state.useBackgroundWakelock, + onCheckedChange = onUseBackgroundWakelockChanged, + ) + } + } + + item(key = "background_heartbeat_card") { + AnimatedVisibility( + visible = state.enableBackgroundSession && state.useBackgroundWakelock, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + HeartbeatFrequencyCard( + currentFrequency = state.heartbeatFrequency, + onFrequencyChanged = onHeartbeatFrequencyChanged, + ) + } + } + + item(key = "background_pause_mode_card") { + BackgroundPauseModeCard( + currentMode = state.backgroundPauseMode, + onModeChanged = onBackgroundPauseModeChanged, + ) + } + + item(key = "integration_section") { + SectionLabel(stringResource(R.string.settings_other_section_integration), modifier = Modifier.padding(top = 8.dp)) + } + item(key = "file_provider_card") { SettingsToggleCard( title = stringResource(R.string.settings_general_enable_file_provider), @@ -1101,3 +1179,225 @@ private fun SmallActionButton( ) } } + +@Composable +private fun BackgroundPauseModeCard( + currentMode: ProcessHelper.BackgroundPauseMode, + onModeChanged: (ProcessHelper.BackgroundPauseMode) -> Unit, +) { + // Tracks if the card is expanded. False = collapsed by default. + var expanded by remember { mutableStateOf(false) } + + // Each entry: mode, title string res, subtitle string res. + val options = listOf( + Triple(ProcessHelper.BackgroundPauseMode.ALL, R.string.settings_other_bg_pause_all_title, R.string.settings_other_bg_pause_all_subtitle), + Triple(ProcessHelper.BackgroundPauseMode.GAME_ONLY, R.string.settings_other_bg_pause_game_only_title, R.string.settings_other_bg_pause_game_only_subtitle), + Triple(ProcessHelper.BackgroundPauseMode.ALL_EXCEPT_GAME,R.string.settings_other_bg_pause_all_except_game_title,R.string.settings_other_bg_pause_all_except_game_subtitle), + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(CardDark) + .border(1.dp, CardBorder, RoundedCornerShape(12.dp)), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + // Header Row - Clicking this toggles expansion + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(IconBoxBg), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + tint = Accent, + modifier = Modifier.size(17.dp), + ) + } + Spacer(Modifier.width(13.dp)) + Text( + text = stringResource(R.string.settings_other_bg_pause_mode_title), + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + // Chevron icon indicating state + Icon( + imageVector = if (expanded) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(20.dp) + ) + } + + // Options list - Only visible when expanded + if (expanded) Spacer(Modifier.height(10.dp)) + options.forEach { (mode, titleRes, subtitleRes) -> + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onModeChanged(mode) } + .padding(vertical = 4.dp, horizontal = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentMode == mode, + onClick = { onModeChanged(mode) }, + colors = RadioButtonDefaults.colors( + selectedColor = Accent, + unselectedColor = TextSecondary, + ), + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(titleRes), + color = TextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(subtitleRes), + color = TextSecondary, + fontSize = 11.sp, + ) + } + } + } + } + } + } +} + +@Composable +private fun HeartbeatFrequencyCard( + currentFrequency: Int, + onFrequencyChanged: (Int) -> Unit, +) { + // Raw text while the user is typing; committed as Int on Done/focus-loss. + var rawText by remember(currentFrequency) { mutableStateOf(currentFrequency.toString()) } + val focusManager = LocalFocusManager.current + + // Derive the effective value and whether the current input is in error, + // so we can give the user immediate feedback without committing bad state. + val parsed = rawText.trim().toIntOrNull() + val isError = parsed == null || (parsed != 0 && parsed < 5) + val effectiveLabel = when { + parsed == null -> stringResource(R.string.settings_other_bg_heartbeat_disabled) + parsed == 0 -> stringResource(R.string.settings_other_bg_heartbeat_disabled) + parsed < 5 -> stringResource(R.string.settings_other_bg_heartbeat_minimum) + else -> stringResource(R.string.settings_other_bg_heartbeat_effective, parsed) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(CardDark) + .border(1.dp, CardBorder, RoundedCornerShape(12.dp)), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(IconBoxBg), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Timer, + contentDescription = null, + tint = Accent, + modifier = Modifier.size(17.dp), + ) + } + Spacer(Modifier.width(13.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_other_bg_heartbeat_title), + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(R.string.settings_other_bg_heartbeat_subtitle), + color = TextSecondary, + fontSize = 11.sp, + ) + } + } + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = rawText, + onValueChange = { rawText = it.filter { c -> c.isDigit() } }, + singleLine = true, + isError = isError, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + commitFrequency(parsed, onFrequencyChanged) + }, + ), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focus -> + // Commit and clamp when the user leaves the field, + // so tapping elsewhere still saves the value. + if (!focus.isFocused) { + commitFrequency(parsed, onFrequencyChanged) + } + }, + suffix = { Text("s", color = TextSecondary, fontSize = 13.sp) }, + supportingText = effectiveLabel.let { label -> + { + Text( + text = label, + color = if (isError) Warning else TextSecondary, + fontSize = 11.sp, + ) + } + }, + ) + } + } +} + +// Extracted so both Done-action and focus-loss share identical clamping logic. +private fun commitFrequency(parsed: Int?, onFrequencyChanged: (Int) -> Unit) { + val committed = when { + parsed == null -> 0 // revert to default on empty / non-numeric + parsed == 0 -> 0 // explicitly disabled + parsed < 5 -> 5 // clamp to minimum + else -> parsed + } + onFrequencyChanged(committed) +} diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index c74b7cc49..f33fbf933 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -267,7 +267,8 @@ class SteamService : Service() { } // The current shared family group the logged in user is joined to. - private var familyGroupMembers: ArrayList = arrayListOf() + // Edited to allow one thread to clear/modify it while others are reading it without crashing. + private val familyGroupMembers = java.util.concurrent.CopyOnWriteArrayList() private val appTokens: ConcurrentHashMap = ConcurrentHashMap() @@ -8444,6 +8445,8 @@ class SteamService : Service() { _unifiedFriends?.close() _unifiedFriends = null + familyGroupMembers.clear() + isStopping = false retryAttempt = 0 reconnectJob?.cancel() @@ -8457,8 +8460,12 @@ class SteamService : Service() { suspendedForBionic = false appInForeground = true - PluviaApp.events.off(onEndProcess) - PluviaApp.events.clearAllListenersOf>() + runCatching { + PluviaApp.events.off(onEndProcess) + } + runCatching { + PluviaApp.events.clearAllListenersOf>() + } } // region [REGION] WN-Steam-Client lifecycle diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a97bffd0c..3c97fe003 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1127,6 +1127,24 @@ E.g. META for META key, \n Unable to take persistable permissions: %1$s Please keep the app open while this finishes. + Background Protection + Background pause mode + Pause all processes + Suspends all processes — Maximum battery saving (May cause issues). + Pause only the game + Suspends only the active game process; other processes and services stays running. + Pause all except the game + Suspends all processes and services but keeps the game process active. + Auto pause session + Auto pause the session when the app is in the background or the device is locked. + Enable a CPU wake lock to enhance background protection + Prevents the CPU from deep-sleeping while the session is paused. Increases battery usage but may improve persistence and stability on aggressive OEMs. + Heartbeat frequency (seconds) + It can improve background app protection. A value of 0 disables the feature. The lower the value, the higher the potential battery consumption. + Heartbeat disabled + Minimum is 5 s — will be clamped on save + Runs every %1$d s + Install Content Category diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 0f82a87a5..479f358e4 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -447,6 +447,7 @@ public boolean isInputSuspended() { private boolean isDarkMode; private boolean enableLogsMenu; + private boolean autoPauseContainer; private static final String TAG = "XServerDisplayActivity"; private static final AtomicLong breadcrumbCounter = new AtomicLong(0); @@ -909,6 +910,12 @@ public void onCreate(Bundle savedInstanceState) { com.winlator.cmod.runtime.system.LogManager.prepareForNewSession(this); preferences = PreferenceManager.getDefaultSharedPreferences(this); + ProcessHelper.setBackgroundPauseMode( + ProcessHelper.getBackgroundPauseMode().fromPrefValue( + preferences.getString("background_pause_mode", ProcessHelper.getBackgroundPauseMode().GAME_ONLY.getPrefValue()) + ) + ); + com.winlator.cmod.runtime.system.ApplicationLogGate.refresh(this); applyPreferredRefreshRate(); launchedFromPinnedShortcut = isPinnedShortcutLaunchIntent(getIntent()); @@ -934,7 +941,7 @@ public void onCreate(Bundle savedInstanceState) { multicastLock.acquire(); } } catch (Exception e) { - Log.w("XServerDisplayActivity", "Failed to acquire MulticastLock", e); + Log.w(TAG, "Failed to acquire MulticastLock", e); } dualSeriesBattery = preferences.getBoolean(FrameRating.PREF_HUD_DUAL_SERIES_BATTERY, false); @@ -944,6 +951,7 @@ public void onCreate(Bundle savedInstanceState) { isTapToClickEnabled = true; boolean isOpenWithAndroidBrowser = preferences.getBoolean("open_with_android_browser", false); boolean isShareAndroidClipboard = preferences.getBoolean("share_android_clipboard", false); + autoPauseContainer = preferences.getBoolean("enable_auto_pause_when_background", false); winHandler = new WinHandler(this); winHandlerStopped.set(false); @@ -996,7 +1004,7 @@ public void run() { if (!isMouseDisabled && xServer != null && xServer.getRenderer() != null && xServer.getRenderer().isCursorVisible()) { xServer.getRenderer().setCursorVisible(false); - Log.d("XServerDisplayActivity", "Mouse cursor hidden after inactivity."); + Log.d(TAG, "Mouse cursor hidden after inactivity."); } }; @@ -2321,63 +2329,8 @@ public void onResume() { boolean cleaningUp = exitRequested.get() || sessionCleanupStarted.get() || activityDestroyed.get(); if (!cleaningUp && environment != null) { - /*xServerView.onResume(); - environment.onResume();*/ - long breadcrumbId = breadcrumbCounter.incrementAndGet(); - - // xServerView.onResume() breadcrumb before - long tBeforeSurfaceNano = System.nanoTime(); - long tBeforeSurfaceMs = System.currentTimeMillis(); - LogManager.logI(TAG, - "breadcrumbId=" + breadcrumbId + " surface resume starts before xServerView.onResume" - + " tsNano=" + tBeforeSurfaceNano + " tsMs=" + tBeforeSurfaceMs - + " thread=" + Thread.currentThread().getName(), - this); - - try { - xServerView.onResume(); - } catch (Throwable t) { - LogManager.logW(TAG, - "breadcrumbId=" + breadcrumbId + " exception during xServerView.onResume", - t, this); - throw t; - } - - long tAfterSurfaceNano = System.nanoTime(); - long tAfterSurfaceMs = System.currentTimeMillis(); - LogManager.logI(TAG, - "breadcrumbId=" + breadcrumbId + " surface resume completed after xServerView.onResume" - + " tsNano=" + tAfterSurfaceNano + " tsMs=" + tAfterSurfaceMs - + " elapsedNs=" + (tAfterSurfaceNano - tBeforeSurfaceNano) - + " thread=" + Thread.currentThread().getName(), - this); - - // environment.onResume() breadcrumb before - long tBeforeEnvNano = System.nanoTime(); - long tBeforeEnvMs = System.currentTimeMillis(); - LogManager.logI(TAG, - "breadcrumbId=" + breadcrumbId + " environment resume starts before environment.onResume" - + " tsNano=" + tBeforeEnvNano + " tsMs=" + tBeforeEnvMs - + " thread=" + Thread.currentThread().getName(), - this); - - try { - environment.onResume(); - } catch (Throwable t) { - LogManager.logW(TAG, - "breadcrumbId=" + breadcrumbId + " exception during environment.onResume", - t, this); - throw t; - } - - long tAfterEnvNano = System.nanoTime(); - long tAfterEnvMs = System.currentTimeMillis(); - LogManager.logI(TAG, - "breadcrumbId=" + breadcrumbId + " environment resume completed after environment.onResume" - + " tsNano=" + tAfterEnvNano + " tsMs=" + tAfterEnvMs - + " elapsedNs=" + (tAfterEnvNano - tBeforeEnvNano) - + " thread=" + Thread.currentThread().getName(), - this); + xServerView.onResume(); + environment.onResume(); } if (inputControlsView != null && touchpadView != null) { @@ -2392,7 +2345,7 @@ public void onResume() { handler.postDelayed(savePlaytimeRunnable, SAVE_INTERVAL_MS); if (!cleaningUp && !isPaused) { - ProcessHelper.resumeAllWineProcesses(); + if (autoPauseContainer) ProcessHelper.resumeAllWineProcesses(); } if (taskManagerPaneVisible && taskManagerTimer == null) { @@ -2404,7 +2357,7 @@ public void onResume() { SessionKeepAliveService.onResumeSession(this); LogManager.log(TAG, "Session resumed", getApplicationContext()); - handler.postDelayed(LogManager::stopEventWatch, 5000); + handler.postDelayed(LogManager::stopEventWatch, 8000); } @Override @@ -2424,10 +2377,12 @@ public void onPause() { boolean cleaningUp = exitRequested.get() || sessionCleanupStarted.get() || activityDestroyed.get(); + if (!cleaningUp) { + if (autoPauseContainer) ProcessHelper.pauseAllWineProcesses(); + } + if (!cleaningUp && !isInPictureInPictureMode()) { if (environment != null) { - ProcessHelper.pauseAllWineProcesses(); - environment.onPause(); xServerView.onPause(); } diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index dbfa5d72a..7aedba101 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -82,6 +82,8 @@ object LogManager { @JvmStatic val isDebugEnabled: Boolean get() = cachedAppDebugEnabled + private var crashHandlerInitialized = false + private fun resolveContext(context: Context?): Context? = context?.applicationContext ?: appContext /** @@ -105,38 +107,23 @@ object LogManager { prefsListener = listener } - /*val app = context.applicationContext - appContext = app - refreshCaches(app) - - if (prefsListener == null) { - val prefs = PreferenceManager.getDefaultSharedPreferences(app) - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - when (key) { - "enable_app_debug", "winlator_path_uri", "app_debug_tags", "app_debug_text_filter" -> - refreshCaches(app) - } - } - prefs.registerOnSharedPreferenceChangeListener(listener) - prefsListener = listener - } - - Log.d(TAG, "LogManager initialized, context name=${app.javaClass.name}, appContext=$appContext") +// Log.d(TAG, "LogManager initialized, context name=${app.javaClass.name}, appContext=$appContext") // Set up uncaught exception handler - val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - if (isDebugEnabledCached) { - logCrash(app, thread, throwable) + if (!crashHandlerInitialized) { + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + if (cachedCrashLogEnabled) { + logCrash(app, thread, throwable) + } + // Call the original handler to maintain default behavior + defaultHandler?.uncaughtException(thread, throwable) } - // Call the original handler to maintain default behavior - defaultHandler?.uncaughtException(thread, throwable) + crashHandlerInitialized = true } // Capture previous exit reasons - if (isDebugEnabledCached && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - logLastExitReasons(app) - }*/ + logLastExitReasons(app) } private fun refreshCaches(context: Context) { @@ -610,11 +597,10 @@ object LogManager { @JvmStatic @JvmOverloads fun logLastExitReasons(context: Context? = null) { if (!cachedExitReasonLogEnabled && !cachedCrashLogEnabled) return - val ctx = resolveContext(context) ?: return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + val ctx = resolveContext(context) ?: return val maxExitReasons = 5 - try { val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager val infos: List = am.getHistoricalProcessExitReasons(ctx.packageName, 0, maxExitReasons) @@ -632,20 +618,43 @@ object LogManager { appendLine( ctx, EXIT_REASONS_FILE, "I/$TAG", - "pid=${info.pid} reason=${info.reason} importance=${info.importance} " + - "desc=${info.description} timestamp=${Date(info.timestamp)}", + "pid=${info.pid} reason=${info.reason}-[${getExitReasonName(info.reason)}] status=${info.status} " + + "importance=${info.importance} desc=${info.description} timestamp=${Date(info.timestamp)}", ) } - if (cachedCrashLogEnabled && info.reason == ApplicationExitInfo.REASON_CRASH_NATIVE) { - try { - info.traceInputStream?.use { input -> - appendLine( - ctx, CRASH_FILE, "I/$TAG", - "Tombstone pid=${info.pid} timestamp=${Date(info.timestamp)}:\n${input.bufferedReader().readText()}", - ) + if (cachedCrashLogEnabled) { + val isErrorReport = when (info.reason) { + ApplicationExitInfo.REASON_CRASH, + ApplicationExitInfo.REASON_CRASH_NATIVE, + ApplicationExitInfo.REASON_ANR -> true + else -> false + } + + if (isErrorReport) { + val type = when (info.reason) { + ApplicationExitInfo.REASON_CRASH -> "Java Crash" + ApplicationExitInfo.REASON_CRASH_NATIVE -> "Native Crash" + ApplicationExitInfo.REASON_ANR -> "ANR" + else -> "Critical Error" + } + + try { + info.traceInputStream?.use { input -> + appendLine( + ctx, CRASH_FILE, "I/$TAG", + "=== Historical $type Detected ===\n" + + "PID: ${info.pid} | Timestamp: ${Date(info.timestamp)}\n" + + "Description: ${info.description}\n" + + "Trace Output:\n${input.bufferedReader().readText()}\n" + + "=== End $type Report ===" + ) + } ?: run { + // If no stream is available, log what we can + appendLine(ctx, CRASH_FILE, "I/$TAG", "Historical $type (No trace available) pid=${info.pid} desc=${info.description}") + } + } catch (e: Exception) { + Timber.tag(TAG).e("Failed to read historical trace: ${e.message}") } - } catch (e: Exception) { - Timber.tag(TAG).e("Failed to read tombstone trace: ${e.message}") } } } @@ -654,11 +663,25 @@ object LogManager { } } - /*@JvmStatic + private fun getExitReasonName(reason: Int): String = when (reason) { + ApplicationExitInfo.REASON_ANR -> "ANR" + ApplicationExitInfo.REASON_CRASH -> "JAVA_CRASH" + ApplicationExitInfo.REASON_CRASH_NATIVE -> "NATIVE_CRASH" + ApplicationExitInfo.REASON_EXIT_SELF -> "EXIT_SELF" + ApplicationExitInfo.REASON_LOW_MEMORY -> "LOW_MEMORY (LMK)" + ApplicationExitInfo.REASON_SIGNALED -> "SIGNALED (KILL)" + ApplicationExitInfo.REASON_USER_REQUESTED -> "USER_REQUESTED (e.g. Swipe)" + ApplicationExitInfo.REASON_USER_STOPPED -> "USER_STOPPED (Force Stop)" + ApplicationExitInfo.REASON_DEPENDENCY_DIED -> "DEPENDENCY_DIED" + ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE -> "EXCESSIVE_RESOURCE_USAGE" + else -> "UNKNOWN_REASON" + } + + @JvmStatic fun logCrash(context: Context, thread: Thread, throwable: Throwable) { try { - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date()) - val fileName = "crash_$timestamp.log" + val timestamp = SimpleDateFormat("yyyy-MM-dd_HH:mm:ss_SSS", Locale.US).format(Date()) + val fileName = "crashFromThread_$timestamp.log" val file = File(getLogsDir(context), fileName) val crashInfo = buildString { @@ -677,5 +700,5 @@ object LogManager { } catch (e: Exception) { Timber.e(e, "Failed to log crash") } - }*/ + } } diff --git a/app/src/main/runtime/system/ProcessHelper.java b/app/src/main/runtime/system/ProcessHelper.java index bffa04d4b..ed5f8adb4 100644 --- a/app/src/main/runtime/system/ProcessHelper.java +++ b/app/src/main/runtime/system/ProcessHelper.java @@ -4,6 +4,7 @@ import android.util.Log; import com.winlator.cmod.shared.util.Callback; import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FilenameFilter; @@ -17,6 +18,8 @@ import java.util.List; import java.util.concurrent.Executors; +import timber.log.Timber; + public abstract class ProcessHelper { private static final String TAG = "ProcessHelper"; private static final int MAX_PROCESS_DETAIL_LENGTH = 240; @@ -58,6 +61,29 @@ public abstract class ProcessHelper { } } + public enum BackgroundPauseMode { + ALL("all"), + GAME_ONLY("game_only"), + ALL_EXCEPT_GAME("all_except_game"); + + private final String prefValue; + + BackgroundPauseMode(String prefValue) { this.prefValue = prefValue; } + public String getPrefValue() { return prefValue; } + + public static BackgroundPauseMode fromPrefValue(String value) { + if (value == null) return GAME_ONLY; + for (BackgroundPauseMode m : values()) { + if (m.prefValue.equals(value)) return m; + } + return GAME_ONLY; + } + } + + private static volatile BackgroundPauseMode backgroundPauseMode = BackgroundPauseMode.GAME_ONLY; + private static volatile int registeredGamePid = -1; + private static final String OOM_TAG = "WNOomProtect"; + public static native int reapDeadChildrenNow(); public static native void startNativeReaperWindow(int durationMs); @@ -215,17 +241,29 @@ private static boolean isCoreProcess(String normalizedData) { return false; } + // Make the OS never OOM-kill the paused process if possible. public static void protectAllWineProcesses() { ArrayList processes = listRunningWineProcesses(); +// String actualAdj = ""; for (String process : processes) { + /*actualAdj = readProcFile("/proc/" + process + "/oom_score_adj"); + LogManager.log(OOM_TAG, "pid=" + process + + " beforeSet=" + (actualAdj != null ? actualAdj.trim() : "unreadable"));*/ + setOomScoreAdj(Integer.parseInt(process), OOM_SCORE_ADJ_PROTECT); + + // Check if the OOM Score is doing something, actually. + /*actualAdj = readProcFile("/proc/" + process + "/oom_score_adj"); + LogManager.log(OOM_TAG, + "pid=" + process + " requested=" + OOM_SCORE_ADJ_PROTECT + + " actual=" + (actualAdj != null ? actualAdj.trim() : "unreadable"));*/ } } public static void pauseAllWineProcesses() { File proc = new File("/proc"); ArrayList processes = listRunningWineProcesses(); - if (!processes.isEmpty()) Log.d(TAG, "Pausing session processes: " + processes); + if (!processes.isEmpty()) LogManager.log(TAG, "Pausing session processes: " + processes); for (String process : processes) { int pid = Integer.parseInt(process); @@ -234,15 +272,30 @@ public static void pauseAllWineProcesses() { String cmdlineData = readProcCmdline(proc, process); String normalized = (statData + " " + cmdlineData).toLowerCase(); - // Make the OS never OOM-kill the paused process if possible. -// setOomScoreAdj(pid, OOM_SCORE_ADJ_PROTECT); - - /*if (isCoreProcess(normalized)) { - if (PRINT_DEBUG) Log.d(TAG, "Skipping SIGSTOP for core process: " + process + " (" + normalized + ")"); - continue; - }*/ + // Check which option the user has selected to pause processes + switch (backgroundPauseMode) { + case ALL: + suspendProcess(pid); + break; + case GAME_ONLY: + if (!isCoreProcess(normalized)) { + suspendProcess(pid); + } else if (PRINT_DEBUG) { + Timber.tag(TAG).d("Skipping SIGSTOP (mode=GAME_ONLY, not game): %s", process); + } + break; + case ALL_EXCEPT_GAME: + if (isCoreProcess(normalized)) { + suspendProcess(pid); + } else if (PRINT_DEBUG) { + Timber.tag(TAG).d("Skipping SIGSTOP (mode=ALL_EXCEPT_GAME, is game): %s", process); + } + break; + default: + break; + } - suspendProcess(pid); +// suspendProcess(pid); } } @@ -252,7 +305,7 @@ public static void resumeAllWineProcesses() { for (String process : processes) { int pid = Integer.parseInt(process); resumeProcess(pid); -// setOomScoreAdj(pid, OOM_SCORE_ADJ_DEFAULT); + setOomScoreAdj(pid, OOM_SCORE_ADJ_DEFAULT); } } @@ -568,8 +621,17 @@ private static String readProcCmdline(File proc, String pid) { } else { // Alternative for compatibility - bytes = new byte[(int) fr.getChannel().size()]; - fr.read(bytes); + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + byte[] data = new byte[4096]; + int nRead; + while ((nRead = fr.read(data)) != -1) { + buffer.write(data, 0, nRead); + } + bytes = buffer.toByteArray(); + } catch (IOException e) { + Log.e(TAG, "Error reading cmdline (API level < 33)", e); + return ""; + } } return new String(bytes, StandardCharsets.UTF_8).replace('\0', ' '); @@ -589,4 +651,30 @@ private static String trimProcessDetail(String detail) { if (detail.length() <= MAX_PROCESS_DETAIL_LENGTH) return detail; return detail.substring(0, MAX_PROCESS_DETAIL_LENGTH - 3) + "..."; } + + public static void setBackgroundPauseMode(BackgroundPauseMode mode) { + backgroundPauseMode = mode != null ? mode : BackgroundPauseMode.ALL; + } + + public static BackgroundPauseMode getBackgroundPauseMode() { + return backgroundPauseMode; + } + + public static void registerGamePid(int pid) { + registeredGamePid = pid; + LogManager.log(TAG, "Registered game process PID: " + pid); + } + + public static void unregisterGamePid() { + LogManager.log(TAG, "Unregistered game process PID (was: " + registeredGamePid + ")"); + registeredGamePid = -1; + } + + private static String readProcFile(String path) { + try { + return new String(java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(path))); + } catch (Exception e) { + return null; + } + } } diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 2ed06c6ab..9ad46abe5 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -7,16 +7,18 @@ import android.app.Service; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.ServiceInfo; import android.os.Build; import android.os.Handler; import android.os.IBinder; import android.os.Looper; -import android.util.Log; +import android.os.PowerManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; +import androidx.preference.PreferenceManager; import com.winlator.cmod.R; import com.winlator.cmod.app.shell.UnifiedActivity; @@ -80,8 +82,19 @@ public class SessionKeepAliveService extends Service { private static volatile boolean isContainerPaused = false; - // private PowerManager.WakeLock wakeLock; + private PowerManager.WakeLock wakeLock; // private WifiManager.WifiLock wifiLock; + + private static volatile SharedPreferences prefs; + private static final String PREF_USE_WAKELOCK = "enable_background_wakelock"; + private static final String PREF_HEARTBEAT_FREQUENCY = "background_heartbeat_frequency"; + private static long heartbeat_interval_ms = 2 * 60 * 1000L; // 2 minutes + + // Dedicated thread replaces Handler.postDelayed() — not subject to + // main-looper message-queue deferral in low-power states. + private volatile Thread heartbeatThread; + private volatile boolean heartbeatRunning = false; + private NotificationHelper notificationHelper; private int notificationId = -1; private static final String NOTIFICATION_ID_NAME = "winnative.keepAlive"; @@ -105,7 +118,7 @@ public void run() { } catch (Exception e) { LogManager.logE(TAG, "Periodic HEARTBEAT protection sweep failed", e, getApplicationContext()); } - }, "WineOomProtection").start(); + }, "SessionOomProtection").start(); protectionHandler.postDelayed(this, 2 * 60 * 1000L); // Every 2 minutes } }; @@ -116,6 +129,17 @@ public void run() { public static void startSession(Context ctx) { if (ctx == null) return; + prefs = PreferenceManager.getDefaultSharedPreferences(ctx.getApplicationContext()); + if (prefs != null) { + int frequency = prefs.getInt(PREF_HEARTBEAT_FREQUENCY, 120); + if (frequency > 0) { + if (frequency < 5) + heartbeat_interval_ms = 5 * 1000L; + else + heartbeat_interval_ms = frequency * 1000L; + } + } + sessionActive.set(true); isContainerPaused = false; isActivityVisible = true; @@ -134,6 +158,11 @@ public static void onPauseSession(Context ctx) { isActivityVisible = false; LogManager.log(TAG, "onPauseSession", ctx); // startProtectionHeartbeat(); + if (instance != null) { + instance.acquireWakeLock(); + instance.runOomSweep(); + instance.startHeartbeat(); + } updateForegroundState(ctx); // sendCommand(ctx, ACTION_SESSION_PAUSE, null); } @@ -148,6 +177,10 @@ public static void onResumeSession(Context ctx) { isActivityVisible = true; LogManager.log(TAG, "onResumeSession", ctx); // stopProtectionHeartbeat(); + if (instance != null) { + instance.stopHeartbeat(); + instance.releaseWakeLock(); + } updateForegroundState(ctx); // sendCommand(ctx, ACTION_SESSION_RESUME, null); } @@ -158,6 +191,10 @@ public static void stopSession(Context ctx) { isContainerPaused = false; // LogManager.log(ctx, TAG, "stopSession"); // stopProtectionHeartbeat(); + if (instance != null) { + instance.stopHeartbeat(); + instance.releaseWakeLock(); + } teardownEnvironmentAsync(); updateForegroundState(ctx); LogManager.log(TAG, "Stopping game session in keep-alive service. Request by: " + Objects.requireNonNull(ctx.getClass().getName()), ctx); @@ -350,6 +387,17 @@ public void onCreate() { // Initialize the helper using the application context notificationHelper = new NotificationHelper(getApplicationContext()); notificationHelper.createNotificationChannel(); // Replace ensureChannel() method. + + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + if (pm != null) { + wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "WinNative:SessionKeepAlive" + ); + // setReferenceCounted(false): acquire/release are idempotent — + // calling release() without a matching acquire() won't throw. + wakeLock.setReferenceCounted(false); + } } @Override @@ -438,18 +486,15 @@ private static void sendCommand(Context ctx, String action, @Nullable String tag intent.setAction(action); if (tag != null) intent.putExtra(EXTRA_TAG, tag); try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - (ACTION_SESSION_START.equals(action) || ACTION_DL_START.equals(action))) { + if (ACTION_SESSION_START.equals(action) || ACTION_DL_START.equals(action)) { app.startForegroundService(intent); } else { app.startService(intent); } } catch (Exception e) { // If starting the service fails, try starting it as a foreground service as a fallback. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - app.startForegroundService(intent); - } - Log.w(TAG, "Failed to send command " + action, e); + app.startForegroundService(intent); + Timber.tag(TAG).w(e, "Failed to send command %s", action); } } @@ -692,6 +737,9 @@ public void onDestroy() { // stopProtectionHeartbeat(); // if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); // if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); + stopHeartbeat(); + releaseWakeLock(); + serviceRunning.set(false); instance = null; super.onDestroy(); @@ -713,6 +761,74 @@ public IBinder onBind(Intent intent) { // Utility methods // =================================================================== + private void acquireWakeLock() { + if (wakeLock == null) return; + if (prefs == null) return; + if (!prefs.getBoolean(PREF_USE_WAKELOCK, false)) return; + if (!wakeLock.isHeld()) { + wakeLock.acquire(); + Timber.tag(TAG).d("WakeLock acquired"); + } + } + + private void releaseWakeLock() { + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + Timber.tag(TAG).d("WakeLock released"); + } + } + + private void startHeartbeat() { + if (prefs == null) return; + if (heartbeatRunning || prefs.getInt(PREF_HEARTBEAT_FREQUENCY, 0) <= 0) return; + heartbeatRunning = true; + Thread t = new Thread(() -> { + while (heartbeatRunning && sessionActive.get() && isContainerPaused) { + try { + Thread.sleep(heartbeat_interval_ms); + LogManager.log(TAG, "Heartbeat: Keeping container alive...", getApplicationContext()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + // Only run the protection sweep while the container is + // actually paused — no work needed in the foreground. + if (!heartbeatRunning || !isContainerPaused) { + break; + } + runOomSweepInternal(); + } + heartbeatRunning = false; + }, "SessionHeartbeat"); + t.setDaemon(true); + t.start(); + heartbeatThread = t; + } + + private void stopHeartbeat() { + heartbeatRunning = false; + Thread t = heartbeatThread; + if (t != null) { + t.interrupt(); + heartbeatThread = null; + } + LogManager.log(TAG, "Heartbeat stopped", this); + } + + private void runOomSweep() { + new Thread(this::runOomSweepInternal, "SessionOomProtection").start(); + LogManager.log(TAG, "OOM protection sweep started", this); + } + + private void runOomSweepInternal() { + try { + ProcessHelper.protectAllWineProcesses(); + } catch (Exception e) { +// Timber.tag(TAG).e(e, "OOM protection sweep failed"); + LogManager.logE(TAG, "OOM protection sweep failed", e, this); + } + } + @NonNull private static String getNotificationContent() { // 1. HIGHEST PRIORITY: The game/container From be703106a965e3760a492b08ab1354689b46c810 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sun, 5 Jul 2026 10:35:21 +0200 Subject: [PATCH 07/19] Background setting enabled by default. - The current setting only enables the foreground for game sessions; the wakelock remains optional, and it is probably not necessary on Android 15 or lower. --- app/src/main/feature/settings/other/OtherSettingsFragment.kt | 2 +- app/src/main/feature/setup/SetupWizardActivity.kt | 4 ++-- app/src/main/runtime/display/XServerDisplayActivity.java | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/feature/settings/other/OtherSettingsFragment.kt b/app/src/main/feature/settings/other/OtherSettingsFragment.kt index ff52d0c77..f781e6352 100644 --- a/app/src/main/feature/settings/other/OtherSettingsFragment.kt +++ b/app/src/main/feature/settings/other/OtherSettingsFragment.kt @@ -252,7 +252,7 @@ class OtherSettingsFragment : Fragment() { openInBrowser = preferences.getBoolean("open_with_android_browser", false), shareClipboard = preferences.getBoolean("share_android_clipboard", false), recordPerformanceToFile = preferences.getBoolean("hud_record_to_file", false), - enableBackgroundSession = preferences.getBoolean("enable_background_session", false), + enableBackgroundSession = preferences.getBoolean("enable_background_session", true), enableAutoPause = preferences.getBoolean("enable_auto_pause_when_background", false), useBackgroundWakelock = preferences.getBoolean("enable_background_wakelock", false), heartbeatFrequency = preferences.getInt("background_heartbeat_frequency", 0), diff --git a/app/src/main/feature/setup/SetupWizardActivity.kt b/app/src/main/feature/setup/SetupWizardActivity.kt index 17a2d13a1..581642ff3 100644 --- a/app/src/main/feature/setup/SetupWizardActivity.kt +++ b/app/src/main/feature/setup/SetupWizardActivity.kt @@ -590,7 +590,7 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { storageGranted.value = hasStoragePermission() notifGranted.value = hasNotificationPermissionSilently() - backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", false) + backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", true) refreshWizardState() loadAdvancedProfiles() @@ -617,7 +617,7 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { if (notificationsEnabled) { notifDenied.value = false } - backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", false) + backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", true) refreshWizardState() refreshRecommendedPackageCache() } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 479f358e4..1098cec04 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -1506,7 +1506,7 @@ public void handleOnBackPressed() { cachedPreloaderSubtitle = container != null ? container.getName() : ""; showLaunchPreloader(getString(R.string.preloader_initializing)); - if (preferences.getBoolean("enable_background_session", false)) { + if (preferences.getBoolean("enable_background_session", true)) { SessionKeepAliveService.startSession(this); } @@ -3749,7 +3749,7 @@ protected void onDestroy() { } if (!sessionCleanupStarted.get()) { - if (exitRequested.get() || !preferences.getBoolean("enable_background_session", false)) { + if (exitRequested.get() || !preferences.getBoolean("enable_background_session", true)) { performForcedSessionCleanup("onDestroy"); } } From 0e713f41cb993a10a1da0ad9bae4e421955458ea Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Mon, 6 Jul 2026 09:48:54 +0200 Subject: [PATCH 08/19] Fixed some errors after merge with main in Debug and Other Settings menus. - Also, added a string to background toggle description/subtitle. Note: The gamepad navigation could be broken because its implementation was made before my new UI options. A more in-depth review is required. --- .../feature/settings/debug/DebugFragment.kt | 2 -- .../feature/settings/debug/DebugScreen.kt | 12 +------- .../settings/other/OtherSettingsFragment.kt | 2 -- .../settings/other/OtherSettingsScreen.kt | 30 +++++++------------ app/src/main/res/values/strings.xml | 1 + 5 files changed, 12 insertions(+), 35 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index f112ccaec..3c32a53b5 100644 --- a/app/src/main/feature/settings/debug/DebugFragment.kt +++ b/app/src/main/feature/settings/debug/DebugFragment.kt @@ -1,6 +1,5 @@ // Settings > Debug fragment — hosts DebugScreen via ComposeView. package com.winlator.cmod.feature.settings -import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.os.Bundle @@ -9,7 +8,6 @@ import android.os.Looper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index 5ed8b6281..3fa408ab8 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -307,7 +307,6 @@ fun DebugScreen( onCheckedChange = onAppDebugChanged, ) - item(key = "exit_reason_log_card") { SettingsToggleCard( title = stringResource(R.string.settings_debug_exit_reason_log_title), subtitle = stringResource(R.string.settings_debug_exit_reason_log_subtitle), @@ -315,9 +314,7 @@ fun DebugScreen( checked = state.exitReasonLog, onCheckedChange = onExitReasonLogChanged, ) - } - item(key = "crash_log_card") { SettingsToggleCard( title = stringResource(R.string.settings_debug_crash_log_title), subtitle = stringResource(R.string.settings_debug_crash_log_subtitle), @@ -325,9 +322,7 @@ fun DebugScreen( checked = state.crashLog, onCheckedChange = onCrashLogChanged, ) - } - item(key = "event_watch_log_card") { SettingsToggleCard( title = stringResource(R.string.settings_debug_event_watch_log_title), subtitle = stringResource(R.string.settings_debug_event_watch_log_subtitle), @@ -335,9 +330,7 @@ fun DebugScreen( checked = state.eventWatchLog, onCheckedChange = onEventWatchLogChanged, ) - } - item(key = "log_tag_filter_card") { AnimatedVisibility( visible = state.appDebug || state.eventWatchLog, enter = fadeIn() + expandVertically(), @@ -354,9 +347,7 @@ fun DebugScreen( }, ) } - } - item(key = "manual_text_filter_field") { AnimatedVisibility( visible = state.appDebug || state.eventWatchLog, enter = fadeIn() + expandVertically(), @@ -381,7 +372,6 @@ fun DebugScreen( .focusProperties { canFocus = state.appDebug || state.eventWatchLog }, ) } - } SectionLabel(stringResource(R.string.settings_debug_section_emulation), modifier = Modifier.padding(top = 8.dp)) @@ -687,7 +677,7 @@ private fun WineChannelsCard( } } -// Wine debug message-class card (err / warn / fixme / trace) +// Wine debug message-class card (err / warn / fix-me / trace) @Composable private fun WineClassesCard( options: List, diff --git a/app/src/main/feature/settings/other/OtherSettingsFragment.kt b/app/src/main/feature/settings/other/OtherSettingsFragment.kt index 41b2f3c14..07e95d844 100644 --- a/app/src/main/feature/settings/other/OtherSettingsFragment.kt +++ b/app/src/main/feature/settings/other/OtherSettingsFragment.kt @@ -13,7 +13,6 @@ import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -253,7 +252,6 @@ class OtherSettingsFragment : Fragment() { enableFileProvider = preferences.getBoolean("enable_file_provider", true), openInBrowser = preferences.getBoolean("open_with_android_browser", false), shareClipboard = preferences.getBoolean("share_android_clipboard", false), - enableBackgroundSession = preferences.getBoolean("enable_background_session", false), enableBackgroundSession = preferences.getBoolean("enable_background_session", true), enableAutoPause = preferences.getBoolean("enable_auto_pause_when_background", false), useBackgroundWakelock = preferences.getBoolean("enable_background_wakelock", false), diff --git a/app/src/main/feature/settings/other/OtherSettingsScreen.kt b/app/src/main/feature/settings/other/OtherSettingsScreen.kt index 5f1b31440..2f9a5bf78 100644 --- a/app/src/main/feature/settings/other/OtherSettingsScreen.kt +++ b/app/src/main/feature/settings/other/OtherSettingsScreen.kt @@ -2,9 +2,6 @@ package com.winlator.cmod.feature.settings import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -13,8 +10,6 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -81,7 +76,6 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource @@ -297,13 +291,12 @@ fun OtherSettingsScreen( SettingsToggleCard( title = stringResource(R.string.settings_general_background), - subtitle = "Keep session alive while in background", + subtitle = stringResource(R.string.settings_other_background_subtitle), icon = Icons.Outlined.Visibility, checked = state.enableBackgroundSession, onCheckedChange = onEnableBackgroundSessionChanged, ) - item(key = "auto_pause_card") { SettingsToggleCard( title = stringResource(R.string.settings_other_bg_auto_pause_title), subtitle = stringResource(R.string.settings_other_bg_auto_pause_subtitle), @@ -311,9 +304,7 @@ fun OtherSettingsScreen( checked = state.enableAutoPause, onCheckedChange = onEnableAutoPauseChanged, ) - } - item(key = "background_wakelock_card") { AnimatedVisibility( visible = state.enableBackgroundSession, enter = fadeIn() + expandVertically(), @@ -327,9 +318,7 @@ fun OtherSettingsScreen( onCheckedChange = onUseBackgroundWakelockChanged, ) } - } - item(key = "background_heartbeat_card") { AnimatedVisibility( visible = state.enableBackgroundSession && state.useBackgroundWakelock, enter = fadeIn() + expandVertically(), @@ -340,14 +329,11 @@ fun OtherSettingsScreen( onFrequencyChanged = onHeartbeatFrequencyChanged, ) } - } - item(key = "background_pause_mode_card") { BackgroundPauseModeCard( currentMode = state.backgroundPauseMode, onModeChanged = onBackgroundPauseModeChanged, ) - } SettingsToggleCard( title = stringResource(R.string.session_drawer_output_to_display), @@ -359,7 +345,6 @@ fun OtherSettingsScreen( SectionLabel(stringResource(R.string.settings_other_section_integration), modifier = Modifier.padding(top = 8.dp)) - item(key = "file_provider_card") { SettingsToggleCard( title = stringResource(R.string.settings_general_enable_file_provider), subtitle = stringResource(R.string.settings_general_file_provider_summary), @@ -466,7 +451,9 @@ private fun SettingsToggleCard( Switch( checked = checked, onCheckedChange = onCheckedChange, - modifier = Modifier.scale(0.78f).focusProperties { canFocus = false }, + modifier = Modifier + .scale(0.78f) + .focusProperties { canFocus = false }, colors = outlinedSwitchColors( accentColor = accentColor, @@ -613,7 +600,8 @@ private fun SettingsDropdownCard( onActivate = { if (options.isNotEmpty()) expanded = true }, highlightColor = NavHighlight, tapToSelect = true, - ).padding(horizontal = 10.dp, vertical = 7.dp) + ) + .padding(horizontal = 10.dp, vertical = 7.dp) .widthIn(max = 180.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -751,7 +739,8 @@ private fun SoundFontCard( onActivate = { if (files.isNotEmpty()) expanded = true }, highlightColor = NavHighlight, tapToSelect = true, - ).padding(horizontal = 10.dp, vertical = 8.dp), + ) + .padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -1117,7 +1106,8 @@ private fun SmallActionButton( onActivate = onClick, highlightColor = NavHighlight, tapToSelect = true, - ).padding(horizontal = 11.dp, vertical = 6.dp), + ) + .padding(horizontal = 11.dp, vertical = 6.dp), contentAlignment = Alignment.Center, ) { Text( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9df51def3..b91660c99 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1134,6 +1134,7 @@ E.g. META for META key, \n Please keep the app open while this finishes. Background Protection + Keep session alive while in background Background pause mode Pause all processes Suspends all processes — Maximum battery saving (May cause issues). From 6a42008e44f792f0303123bf47c785f73d45043f Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Fri, 10 Jul 2026 14:13:48 +0200 Subject: [PATCH 09/19] Resolved conflicts after merge with main (Steam Friends & Controller navigation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - NotificationHelper - Commented out the third channel, `CHAT_BG_CHANNEL`, because it was only used to show a notification that Steam chat is still running in the background. Replaced by adding the relevant message to Winnative’s main foreground channel notification. - SteamService - Commented out the code related to the previous channel, which could also cause an exception when trying to start a foreground service while the app is already in the background. In addition, the current code only needs one master foreground service, not an extra one. - Added some `stopComponent` lines in various parts of the code to ensure that SessionKeepAliveService stays up to date with the state of SteamService. - DebugScreen and OtherSettingsScreen menus - Updated to work with the new controller navigation implementation. - SessionKeepAliveService, build.gradle and libs.versions.toml - Implemented a lifecycle observer in the class to better manage the foreground, starting with the functionality of the notification to be shown and its exit button. - For this, build.gradle had to be modified with a new dependency, which then automatically updated libs.versions.toml as well. - Now the notification from the container running in the background reliably shows the Exit button. - If Steam chat is set to stay open when the app closes, the Exit button in the previous notification properly closes the container and keeps the app running. If it is not set that way, Exit properly closes the container and then closes the app. - Added a new medium-low priority message between "Download" and "Stores" in the foreground notification that will inform the user that Steam Chat is running if the app is in the background and Steam Chat is enabled. - Refactored some code to avoid duplicity. - XServerDisplayActivity - Added a conditional to prevent Winnative from interrupting the user’s activity (appearing in the foreground while the user is doing something else) if the container has been closed from the notification (Exit) and the app is in the background. --- app/build.gradle | 19 +- .../feature/settings/debug/DebugScreen.kt | 14 + .../settings/other/OtherSettingsScreen.kt | 38 ++- .../stores/steam/service/SteamService.kt | 54 ++-- .../display/XServerDisplayActivity.java | 9 + .../system/SessionKeepAliveService.java | 223 +++++++++++--- .../main/shared/android/NotificationHelper.kt | 284 +++++++----------- gradle/libs.versions.toml | 2 + 8 files changed, 388 insertions(+), 255 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ba0454043..c43cc82c8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -161,18 +161,18 @@ android { flavorDimensions "branding" productFlavors { - standard { + /*standard { dimension "branding" - applicationId "com.winnative.cmod" - } - ludashi { + applicationId "com.winnative.test" + }*/ + debugTest1 { dimension "branding" - applicationId "com.ludashi.benchmark" + applicationId "com.winnative.test1" } - pubg { + /*debugTest2 { dimension "branding" - applicationId "com.tencent.ig" - } + applicationId "com.winnative.test2" + }*/ } ndkVersion '27.3.13750724' @@ -237,7 +237,7 @@ android { } dependencies { - // debugImplementation "com.squareup.leakcanary:leakcanary-android:2.14" +// debugImplementation "com.squareup.leakcanary:leakcanary-android:2.14" implementation libs.okhttp implementation libs.okhttpDnsOverHttps @@ -285,6 +285,7 @@ dependencies { implementation files('libs/MidiSynth/MidiSynth.jar') implementation libs.recyclerview implementation libs.coreKtx + implementation("androidx.lifecycle:lifecycle-process:2.9.0") // New = compilation errors / gradle plugin update requirement. implementation 'com.android.ndk.thirdparty:openssl:1.1.1q-beta-1' diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index 3fa408ab8..2e26fd50d 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -1301,6 +1301,13 @@ private fun SegmentedControl( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .background(if (isSelected) Accent else Color.Transparent) + // Add paneNavItem so the controller can focus this option + .paneNavItem( + cornerRadius = 8.dp, + onActivate = { onSelectedIndex(index) }, + highlightColor = NavHighlight, + tapToSelect = true + ) .clickable { onSelectedIndex(index) } .padding(horizontal = 12.dp, vertical = 6.dp), contentAlignment = Alignment.Center, @@ -1332,6 +1339,13 @@ private fun RemovableTagChip(tag: String, onRemove: () -> Unit) { modifier = Modifier .size(20.dp) .clip(RoundedCornerShape(10.dp)) + // Add paneNavItem to the 'X' button container + .paneNavItem( + cornerRadius = 10.dp, + onActivate = { onRemove() }, + highlightColor = NavHighlight, + tapToSelect = true + ) .clickable { onRemove() }, contentAlignment = Alignment.Center, ) { diff --git a/app/src/main/feature/settings/other/OtherSettingsScreen.kt b/app/src/main/feature/settings/other/OtherSettingsScreen.kt index 2f9a5bf78..795aaadea 100644 --- a/app/src/main/feature/settings/other/OtherSettingsScreen.kt +++ b/app/src/main/feature/settings/other/OtherSettingsScreen.kt @@ -1150,7 +1150,14 @@ private fun BackgroundPauseModeCard( Row( modifier = Modifier .fillMaxWidth() - .clickable { expanded = !expanded }, + .clip(RoundedCornerShape(8.dp)) + .paneNavItem( + cornerRadius = 8.dp, + onActivate = { expanded = !expanded }, + highlightColor = NavHighlight, + tapToSelect = true, + ) + .padding(vertical = 4.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically ) { Box( @@ -1195,8 +1202,14 @@ private fun BackgroundPauseModeCard( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .clickable { onModeChanged(mode) } - .padding(vertical = 4.dp, horizontal = 2.dp), + .clip(RoundedCornerShape(8.dp)) + .paneNavItem( + cornerRadius = 8.dp, + onActivate = { onModeChanged(mode) }, + highlightColor = NavHighlight, + tapToSelect = true, + ) + .padding(vertical = 4.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { RadioButton( @@ -1206,6 +1219,8 @@ private fun BackgroundPauseModeCard( selectedColor = Accent, unselectedColor = TextSecondary, ), + // Let the Row handle the focus + modifier = Modifier.focusProperties { canFocus = false } ) Spacer(Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { @@ -1308,6 +1323,20 @@ private fun HeartbeatFrequencyCard( ), modifier = Modifier .fillMaxWidth() + .paneNavItem( + cornerRadius = 8.dp, + // onAdjust allows changing the value with D-pad + onAdjust = { dir -> + // Calculate the next value using a step of 5 + val next = when { + currentFrequency == 5 && dir < 0 -> 0 // If at 5 and pressing Left, jump to 0 (Disabled) + currentFrequency == 0 && dir > 0 -> 5 // If at 0 and pressing Right, jump to 5 (Minimum) + else -> (currentFrequency + dir * 5).coerceAtLeast(0) // Standard increment/decrement + } + onFrequencyChanged(next) + }, + highlightColor = NavHighlight, + ) .onFocusChanged { focus -> // Commit and clamp when the user leaves the field, // so tapping elsewhere still saves the value. @@ -1333,8 +1362,7 @@ private fun HeartbeatFrequencyCard( // Extracted so both Done-action and focus-loss share identical clamping logic. private fun commitFrequency(parsed: Int?, onFrequencyChanged: (Int) -> Unit) { val committed = when { - parsed == null -> 0 // revert to default on empty / non-numeric - parsed == 0 -> 0 // explicitly disabled + parsed == null || parsed == 0 -> 0 // revert to default on empty / non-numeric -> disabled parsed < 5 -> 5 // clamp to minimum else -> parsed } diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 2482ccb86..6a507d80a 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -7,13 +7,11 @@ import android.util.Base64 import android.util.Log import android.widget.Toast import androidx.room.withTransaction -import com.winlator.cmod.BuildConfig import com.winlator.cmod.R import com.winlator.cmod.app.PluviaApp import com.winlator.cmod.app.db.PluviaDatabase import com.winlator.cmod.app.db.download.DownloadRecord import com.winlator.cmod.app.service.DownloadService -import com.winlator.cmod.app.service.NetworkMonitor import com.winlator.cmod.app.service.download.DownloadCoordinator import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils import com.winlator.cmod.feature.stores.steam.data.AppInfo @@ -43,10 +41,7 @@ import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao -import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase -import com.winlator.cmod.feature.stores.steam.enums.GameSource -import com.winlator.cmod.feature.stores.steam.enums.Language import com.winlator.cmod.feature.stores.steam.enums.LoginResult import com.winlator.cmod.feature.stores.steam.enums.Marker import com.winlator.cmod.feature.stores.steam.enums.OS @@ -86,19 +81,15 @@ import com.winlator.cmod.feature.stores.steam.utils.SteamUtils import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud -import com.winlator.cmod.feature.sync.google.CloudSyncManager import com.winlator.cmod.runtime.container.Container import com.winlator.cmod.runtime.container.ContainerManager import com.winlator.cmod.runtime.display.environment.ImageFs -import com.winlator.cmod.runtime.system.GPUInformation import com.winlator.cmod.runtime.system.LogManager 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.StorageUtils import dagger.hilt.android.AndroidEntryPoint -import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags import com.winlator.cmod.feature.stores.steam.enums.ELicenseType import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod @@ -122,7 +113,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.channels.BufferOverflow 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 @@ -130,15 +120,12 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.future.await import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout import okhttp3.FormBody import okhttp3.Request import org.json.JSONArray @@ -153,7 +140,6 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.file.Files import java.nio.file.Paths -import java.util.Collections import java.util.Date import java.util.EnumSet import java.util.concurrent.ConcurrentHashMap @@ -200,9 +186,11 @@ class SteamService : Service() { @Inject lateinit var downloadingAppInfoDao: DownloadingAppInfoDao - /*private lateinit var notificationHelper: NotificationHelper - var notificationID = 1 +// private lateinit var notificationHelper: NotificationHelper + /*var notificationID = 1 var preferences: SharedPreferences? = null*/ + /*private var STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID = -3 // Previus default: 3 + private val STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID_NAME = "winnative.steamChat"*/ private var _unifiedFriends: SteamUnifiedFriends? = null @@ -7553,7 +7541,12 @@ class SteamService : Service() { instance = this /*notificationHelper = NotificationHelper(applicationContext) - val notification = notificationHelper.createForegroundNotification("Steam Service is running") + // Assing a unique value to this notifiaction ID + if (STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID < 0) { + STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID = notificationHelper.generateNotificationId(this, STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID_NAME) + }*/ + + /*val notification = notificationHelper.createForegroundNotification("Steam Service is running") startForeground(1, notification)*/ com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient @@ -7732,7 +7725,11 @@ class SteamService : Service() { backgroundIdleJob?.cancel() backgroundIdleJob = null - // ToDo start: Check this steam friends code + /** ToDo start: Check this steam friends code + * I don’t really understand this part or how to handle it. The whole point of a + * ForegroundService is to be started to protect the app while it’s in the background, + * not to start it every time the app returns from the background. + */ // Restore the quiet foreground notification and drop the background-chat one. /*if (isRunning && !isStopping) { runCatching { @@ -7740,6 +7737,12 @@ class SteamService : Service() { notificationHelper.cancelBackgroundRunning() }.onFailure { Timber.w(it, "Failed to restore SteamService foreground notification") } }*/ + // Updated code + /*if (isRunning && !isStopping) { + runCatching { + notificationHelper.cancel(STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID) + }.onFailure { Timber.w(it, "Failed to cancel Steam Chat in background notification channel") } + }*/ // ToDo end. if (!suspendedForBackground) return suspendedForBackground = false @@ -7757,7 +7760,12 @@ class SteamService : Service() { /** App went to the background — arm the deferred suspend check. */ private fun handleAppBackgrounded() { appInForeground = false - // ToDo start: Check this Steam Friends code + /** ToDo start: Check this Steam Friends code + * Regarding this part, based on my previous PR about the background, doing this is dangerous: + * the OS could interpret it as an attempt to start while already in the background, + * which would throw an exception because Android does not allow that behavior. + * Also, this just show a message, it doesn't change the friend notification channel. + */ /*if (PrefManager.chatStayRunningOnExit && isRunning && !isStopping) { runCatching { startForeground( @@ -7819,11 +7827,16 @@ class SteamService : Service() { messagePollerJob?.cancel() wnSession?.let { s -> runCatching { s.disconnect() } } // ToDo: Check this Steam Friends code: - scope.launch(Dispatchers.Main) { + /*scope.launch(Dispatchers.Main) { runCatching { stopForeground(STOP_FOREGROUND_REMOVE) } .onFailure { Timber.w(it, "Failed to remove SteamService foreground state on background suspend") } runCatching { notificationHelper.cancel() } .onFailure { Timber.w(it, "Failed to cancel SteamService notification on background suspend") } + }*/ + // Background persistance updated code + scope.launch(Dispatchers.Main) { + runCatching { SessionKeepAliveService.stopComponent(applicationContext, SessionKeepAliveService.COMPONENT_STEAM) } + .onFailure { Timber.w(it, "Failed to remove SteamService foreground state on background suspend") } } // ToDo end. return true @@ -8004,6 +8017,7 @@ class SteamService : Service() { runCatching { s.close() } } wnSession = null + SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM) clearValues() } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index c133edaa8..52ffb50e8 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -3049,6 +3049,15 @@ private void closeAfterSessionExit() { return; } + // If the app is already in the background (e.g. the user pressed Exit + // from the notification while using another app), just finish this + // Activity without starting UnifiedActivity — doing so would bring + // WinNative in front of whatever the user was doing. + if (SessionKeepAliveService.isAppVisible()) { + finish(); + return; + } + returnToUnifiedActivity(); } diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 9ad46abe5..a770e2bb2 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -1,12 +1,16 @@ package com.winlator.cmod.runtime.system; +import android.app.ActivityManager; +import android.app.KeyguardManager; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; +import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.ServiceInfo; import android.os.Build; @@ -22,6 +26,7 @@ import com.winlator.cmod.R; import com.winlator.cmod.app.shell.UnifiedActivity; +import com.winlator.cmod.feature.stores.steam.utils.PrefManager; import com.winlator.cmod.runtime.display.XServerDisplayActivity; import com.winlator.cmod.runtime.display.environment.XEnvironment; import com.winlator.cmod.runtime.display.xserver.XServer; @@ -69,9 +74,9 @@ public class SessionKeepAliveService extends Service { private static final String ACTION_REMOVE_COMPONENT = "com.winlator.cmod.action.REMOVE_COMPONENT"; public static final String COMPONENT_STEAM = "Steam"; +// public static final String COMPONENT_STEAM_FRIENDS = "Steam Friends"; public static final String COMPONENT_EPIC = "Epic"; public static final String COMPONENT_GOG = "GOG"; - public static final String COMPONENT_CONTAINER = "Container"; private static final AtomicBoolean sessionActive = new AtomicBoolean(false); private static final HashSet activeDownloads = new HashSet<>(); @@ -82,7 +87,15 @@ public class SessionKeepAliveService extends Service { private static volatile boolean isContainerPaused = false; - private PowerManager.WakeLock wakeLock; + // ── App visibility state ────────────────────────────────────────────── + // isAppInBackground: true when no Activity is STARTED (ProcessLifecycleOwner). + // isScreenLocked: true after ACTION_SCREEN_OFF, cleared by ACTION_USER_PRESENT. + // Both are updated from the main thread; volatile is sufficient for reads. + private static volatile boolean isAppInBackground = false; + private static volatile boolean isScreenLocked = false; + private BroadcastReceiver screenStateReceiver; + + private PowerManager.WakeLock wakeLock; // private WifiManager.WifiLock wifiLock; private static volatile SharedPreferences prefs; @@ -308,6 +321,13 @@ public static void stopComponent(Context ctx, String componentName) { } }*/ + public static boolean isAppInBackground() { return isAppInBackground; } + public static boolean isDeviceLocked() { return isScreenLocked; } + + public static boolean isAppVisible() { + return isAppInBackground || isScreenLocked; + } + // =================================================================== // Background download tracking // =================================================================== @@ -341,7 +361,9 @@ public static void stopDownload(Context ctx, String tag) { // =================================================================== private static boolean hasReason() { - return sessionActive.get() || !activeDownloads.isEmpty() || !activeComponents.isEmpty(); + return sessionActive.get() || !activeDownloads.isEmpty() || !activeComponents.isEmpty() || + (isAppVisible() && PrefManager.INSTANCE.getChatStayRunningOnExit()); + /*if (sessionActive.get()) return true; synchronized (activeDownloads) { return !activeDownloads.isEmpty(); @@ -398,10 +420,65 @@ public void onCreate() { // calling release() without a matching acquire() won't throw. wakeLock.setReferenceCounted(false); } + + // Seed initial state from current lifecycle rather than assuming foreground. + isAppInBackground = !androidx.lifecycle.ProcessLifecycleOwner.get() + .getLifecycle().getCurrentState() + .isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED); + + androidx.lifecycle.ProcessLifecycleOwner.get() + .getLifecycle() + .addObserver(appLifecycleObserver); + + // Screen-lock detection. ACTION_SCREEN_OFF/USER_PRESENT are protected + // broadcasts — dynamic registration only, no manifest entry needed. + screenStateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (Intent.ACTION_SCREEN_OFF.equals(action)) { + isScreenLocked = true; + LogManager.log(TAG, "Screen turned off / device locked"); + } else if (Intent.ACTION_USER_PRESENT.equals(action)) { + isScreenLocked = false; + LogManager.log(TAG, "Device unlocked (user present)"); + } else if (Intent.ACTION_SCREEN_ON.equals(action)) { + // Screen on but keyguard may still be showing. + KeyguardManager km = (KeyguardManager) getSystemService(KEYGUARD_SERVICE); + isScreenLocked = km != null && km.isKeyguardLocked(); + } + updateForegroundState(context); + } + }; + + IntentFilter screenFilter = new IntentFilter(); + screenFilter.addAction(Intent.ACTION_SCREEN_OFF); + screenFilter.addAction(Intent.ACTION_SCREEN_ON); + screenFilter.addAction(Intent.ACTION_USER_PRESENT); + registerReceiver(screenStateReceiver, screenFilter); } @Override public int onStartCommand(Intent intent, int flags, int startId) { + String action = intent != null ? intent.getAction() : null; + + // Handle the Exit button from the notification + if (ACTION_SESSION_STOP.equals(action)) { + boolean chatStayAlive = PrefManager.INSTANCE.getChatStayRunningOnExit(); + + // If a game is running, and we want to keep chat alive, only stop the session. + // updateForegroundState() will be called inside stopSession, + // and it will update the notification to the "Chat" state. + if (sessionActive.get() && chatStayAlive) { + stopSession(this); + cleanUpSession(this, "Exit button pressed"); + } else { + // Otherwise, perform a full app shutdown. + closeApp(this); + } + return START_NOT_STICKY; + } + // State is already current by the time this runs — every caller mutates // it before this Intent is ever sent. This just reconciles the actual // foreground/running status against that state. @@ -409,7 +486,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { ensureForeground(); serviceRunning.set(true); } else { - Timber.tag(TAG).d("onStartCommand found no active reason; stopping immediately"); + Timber.tag(TAG).d("onStartCommand found no active reason; stopping immediately"); stopForegroundCompat(); stopSelf(); serviceRunning.set(false); @@ -419,17 +496,17 @@ public int onStartCommand(Intent intent, int flags, int startId) { private void ensureForeground() { boolean containerActive = sessionActive.get(); - // Only show Exit button if container is running AND app is in background - boolean showExit = containerActive && !isActivityVisible; + // Only show Exit button if app is in background AND container is running or user wants to keep steam chat alive. + boolean showExit = isAppVisible() && (containerActive || PrefManager.INSTANCE.getChatStayRunningOnExit()); // Determine target activity: Game screen if active, else Main menu Class targetActivity = containerActive ? XServerDisplayActivity.class : UnifiedActivity.class; Notification n = notificationHelper.createForegroundNotification( getNotificationContent(), - "WinNative", // Title - SessionKeepAliveService.class, // Service class for the 'Exit' action - showExit ? ACTION_SESSION_STOP : null, // Exit only for backgrounded container + "WinNative", + SessionKeepAliveService.class, + showExit ? ACTION_SESSION_STOP : null, // Exit only for backgrounded app targetActivity // Activity class for the 'Open' (notification tap) action ); @@ -694,42 +771,10 @@ public void onTaskRemoved(Intent rootIntent) { super.onTaskRemoved(rootIntent); LogManager.logI(TAG, "Task removed (user swipe). Tearing down session and exiting process.", this); - sessionActive.set(false); - isContainerPaused = false; - isActivityVisible = false; - synchronized (activeDownloads) { activeDownloads.clear(); } - activeComponents.clear(); + resetLocalState(); // stopProtectionHeartbeat(); - // Give the activity's own onDestroy → performForcedSessionCleanup a - // chance to run first; then defensively clean any wine processes that - // might still be alive, and exit the process so swipe behaves like the - // pre-existing "swipe-away closes everything" flow. - new Thread(() -> { - try { - Thread.sleep(1500L); - if (com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isBionicHandoffActive()) { - try { - boolean kicked = com.winlator.cmod.feature.stores.steam.service.SteamService - .Companion.bionicHandoffReleaseAndKickPlayingSessionBlocking(true, 2500L); - LogManager.logI(TAG, "Task removal Steam cleanup: kickedPlayingSession=" + kicked, this); - } catch (Throwable t) { - LogManager.logW(TAG, "Task removal Steam cleanup failed", t, this); - } - } - ProcessHelper.terminateSessionProcessesAndWait(1500, true); - ProcessHelper.drainDeadChildren("session keep-alive task removed"); - } catch (Throwable t) { - LogManager.logW(TAG, "Defensive wine cleanup on task removal failed", t, this); - } - new Handler(Looper.getMainLooper()).post(() -> { - stopForegroundCompat(); - stopSelf(); - serviceRunning.set(false); - new Handler(Looper.getMainLooper()).postDelayed( - () -> android.os.Process.killProcess(android.os.Process.myPid()), 500L); - }); - }, "SessionKeepAliveCleanup").start(); + performDefensiveCleanupAndExit(this); } @Override @@ -740,6 +785,15 @@ public void onDestroy() { stopHeartbeat(); releaseWakeLock(); + androidx.lifecycle.ProcessLifecycleOwner.get() + .getLifecycle() + .removeObserver(appLifecycleObserver); + + if (screenStateReceiver != null) { + try { unregisterReceiver(screenStateReceiver); } catch (Exception ignored) {} + screenStateReceiver = null; + } + serviceRunning.set(false); instance = null; super.onDestroy(); @@ -841,7 +895,11 @@ private static String getNotificationContent() { if (!activeDownloads.isEmpty()) return "Downloading and installing components in the background"; } - // 3. LOW PRIORITY: Active Stores + // 3. MEDIUM PRIORITY: Steam friends (if enabled) + if (PrefManager.INSTANCE.getChatStayRunningOnExit() && isAppVisible()) + return "Steam chat running in background"; + + // 4. LOW PRIORITY: Active store services if (!activeComponents.isEmpty()) { List names = new ArrayList<>(activeComponents.keySet()); return names.size() == 1 @@ -851,9 +909,78 @@ private static String getNotificationContent() { return "WinNative is running in the background"; } + private final androidx.lifecycle.DefaultLifecycleObserver appLifecycleObserver = + new androidx.lifecycle.DefaultLifecycleObserver() { + @Override + public void onStart(@NonNull androidx.lifecycle.LifecycleOwner owner) { + isAppInBackground = false; + LogManager.log(TAG, "App came to foreground (ProcessLifecycleOwner)"); + updateForegroundState(SessionKeepAliveService.this); + } + + @Override + public void onStop(@NonNull androidx.lifecycle.LifecycleOwner owner) { + isAppInBackground = true; + LogManager.log(TAG, "App went to background (ProcessLifecycleOwner)"); + updateForegroundState(SessionKeepAliveService.this); + } + }; + + private static void resetLocalState() { + sessionActive.set(false); + isContainerPaused = false; + isActivityVisible = false; + synchronized (activeDownloads) { activeDownloads.clear(); } + activeComponents.clear(); + } + + /** + * Performs a deep cleanup of native processes and terminates the app PID. + * This is the shared logic between swiping away and clicking "Exit". + */ + private static void performDefensiveCleanupAndExit(Context ctx) { + // Give the activity's own onDestroy → performForcedSessionCleanup a + // chance to run first; then defensively clean any wine processes that + // might still be alive, and exit the process so swipe/exit button behaves like the + // pre-existing "swipe-away closes everything" flow. + new Thread(() -> { + try { + Thread.sleep(1500L); + cleanUpSession(ctx, "session keep-alive shutdown"); + } catch (Throwable t) { + LogManager.logW(TAG, "Defensive cleanup failed", t, ctx); + } + + new Handler(Looper.getMainLooper()).post(() -> { + if (instance != null) { + instance.stopForegroundCompat(); + instance.stopSelf(); + } + serviceRunning.set(false); + // Final kill + new Handler(Looper.getMainLooper()).postDelayed( + () -> android.os.Process.killProcess(android.os.Process.myPid()), 500L); + }); + }, "SessionCleanupAndExit").start(); + } + + private static void cleanUpSession(Context ctx, String reason) { + if (com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isBionicHandoffActive()) { + try { + boolean kicked = com.winlator.cmod.feature.stores.steam.service.SteamService + .Companion.bionicHandoffReleaseAndKickPlayingSessionBlocking(true, 2500L); + LogManager.logI(TAG, "Task removal/Exit button - Steam cleanup: kickedPlayingSession=" + kicked, ctx); + } catch (Throwable t) { + LogManager.logW(TAG, "Task removal/Exit button - Steam cleanup failed", t, ctx); + } + } + ProcessHelper.terminateSessionProcessesAndWait(1500, true); + ProcessHelper.drainDeadChildren(reason); + } + public static void stopAll(Context ctx) { stopSession(ctx); - activeComponents.clear(); + resetLocalState(); // Stop Steam specifically com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.stop(); @@ -865,4 +992,10 @@ public static void stopAll(Context ctx) { svc.stopSelf(); } } + + // Stop everything and kill the app process. + public static void closeApp(Context ctx) { + stopAll(ctx); + performDefensiveCleanupAndExit(ctx); + } } diff --git a/app/src/main/shared/android/NotificationHelper.kt b/app/src/main/shared/android/NotificationHelper.kt index 4c15782aa..56f97aa24 100644 --- a/app/src/main/shared/android/NotificationHelper.kt +++ b/app/src/main/shared/android/NotificationHelper.kt @@ -27,15 +27,19 @@ class NotificationHelper // This constant is passed directly from the class that starts the notification. //private const val NOTIFICATION_ID = 1 - // ToDo start: Move this dependencies to the proper Steam class private const val CHAT_CHANNEL_ID = "winnative_steam_chat" private const val CHAT_CHANNEL_NAME = "Steam Chat" - const val BACKGROUND_RUNNING_NOTIFICATION_ID = 3 - private const val CHAT_BG_CHANNEL_ID = "winnative_chat_background" - private const val CHAT_BG_CHANNEL_NAME = "Steam Background" + /** At the time of writing this, this channel only shows a single notification. + * No code has been found indicating that friend chat notifications change channel + * when going to the background; the only change is that the old SteamService foreground + * is replaced by this channel’s foreground when the app goes into the background + * (which may cause an exception). For this reason, it's commented out. Can be deleted. + */ + /*private const val CHAT_BG_CHANNEL_ID = "winnative_steam_chat_background" + private const val CHAT_BG_CHANNEL_NAME = "Steam Chat Background"*/ + const val EXTRA_OPEN_CHAT_FRIEND_ID = BuildConfig.APPLICATION_ID + ".OPEN_CHAT_FRIEND_ID" - // ToDo end. const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".EXIT" } @@ -44,141 +48,34 @@ class NotificationHelper context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager init { + // Default channel createNotificationChannel() - } - - - // ToDo Start: Review Steam Friend changes in notification behaivour - - private fun createNotificationChannel() { - val channel = - NotificationChannel( - CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = "Allows to display WinNative foreground notifications" - setShowBadge(false) - } - - notificationManager.createNotificationChannel(channel) - val chatChannel = - NotificationChannel( + // Steam chat channel + createNotificationChannel( + context.applicationContext, CHAT_CHANNEL_ID, CHAT_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH, - ).apply { - description = "Incoming Steam friend messages" - setShowBadge(true) - } - - notificationManager.createNotificationChannel(chatChannel) - - val backgroundChannel = - NotificationChannel( - CHAT_BG_CHANNEL_ID, - CHAT_BG_CHANNEL_NAME, - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = "Shown while Steam chat keeps running after you exit" - setShowBadge(false) - setSound(null, null) - enableVibration(false) - } - - notificationManager.createNotificationChannel(backgroundChannel) - } - - 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) { - val notification = createForegroundNotification(content) - notificationManager.notify(NOTIFICATION_ID, notification) - } - - fun cancel() { - notificationManager.cancel(NOTIFICATION_ID) - } - - fun cancelBackgroundRunning() { - notificationManager.cancel(BACKGROUND_RUNNING_NOTIFICATION_ID) - } - - fun createBackgroundRunningNotification(): Notification { - val intent = - Intent(context, UnifiedActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - val pendingIntent = - PendingIntent.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - val stopIntent = - Intent(context, SteamService::class.java).apply { - action = ACTION_EXIT - } - val stopPendingIntent = - PendingIntent.getForegroundService( - context, - 0, - stopIntent, - PendingIntent.FLAG_IMMUTABLE, + "Incoming Steam friend messages", + true ) - return NotificationCompat - .Builder(context, CHAT_BG_CHANNEL_ID) - .setContentTitle(context.getString(R.string.common_ui_app_name)) - .setContentText("Steam chat running in background") - .setSmallIcon(R.drawable.ic_notification) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setOnlyAlertOnce(true) - .setAutoCancel(false) - .setOngoing(true) - .setContentIntent(pendingIntent) - .addAction(0, "Exit", stopPendingIntent) - .build() - } - // ToDo end. + // Reason why it has been commented explained in the companion variables. + /*val backgroundChannel = + NotificationChannel( + CHAT_BG_CHANNEL_ID, + CHAT_BG_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = "Shown while Steam chat keeps running after you exit" + setShowBadge(false) + setSound(null, null) + enableVibration(false) + } - /** - * Refactored code: - */ + notificationManager.createNotificationChannel(backgroundChannel)*/ + } // Sends or updates a notification. fun notify( @@ -238,52 +135,52 @@ class NotificationHelper return builder.build() } - /** - * Create a notification channel. - * @param context The context of the app. - * @param channelId Unique channel identifier. - * @param name Visible channel name for the user. - * @param importance Importance level (e.g., NotificationManager.IMPORTANCE_LOW). - * @param desc Channel description (optional). - */ - fun createNotificationChannel( - context: Context, - channelId: String?, - name: String?, - importance: Int, - desc: String - ) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val nm = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager? - if (nm != null && nm.getNotificationChannel(channelId) == null) { - val channel = - NotificationChannel( - channelId, - name, - importance - ).apply { - if (!desc.isEmpty()) description = "" - setShowBadge(false) - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - } - - nm.createNotificationChannel(channel) + /** + * Create a notification channel. + * @param context The context of the app. + * @param channelId Unique channel identifier. + * @param name Visible channel name for the user. + * @param importance Importance level (e.g., NotificationManager.IMPORTANCE_LOW). + * @param desc Channel description (optional). + * @param showBadge Whether to show a badge for this channel (optional). + */ + fun createNotificationChannel( + context: Context, + channelId: String?, + name: String?, + importance: Int, + desc: String, + showBadge: Boolean + ) { + val nm = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager? + if (nm != null && nm.getNotificationChannel(channelId) == null) { + val channel = + NotificationChannel( + channelId, + name, + importance + ).apply { + if (desc.isNotEmpty()) description = desc + setShowBadge(showBadge) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC } - } - } - /** - * Overload of createNotificationChannel() - * without description. - */ - fun createNotificationChannel( - context: Context, - channelId: String?, - name: String?, - importance: Int - ) { - createNotificationChannel(context, channelId, name, importance, "") + nm.createNotificationChannel(channel) } + } + + /** + * Overload of createNotificationChannel() + * without description. + */ + fun createNotificationChannel( + context: Context, + channelId: String?, + name: String?, + importance: Int + ) { + createNotificationChannel(context, channelId, name, importance, "", false) + } /** * Overload of createNotificationChannel() @@ -295,7 +192,8 @@ class NotificationHelper CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW, - "Allows to display WinNative foreground notifications" + "Allows to display WinNative foreground notifications", + false ) /*val channel = @@ -324,4 +222,38 @@ class NotificationHelper val contextKey = context.packageName + notificationIDName return contextKey.hashCode() and 0x7FFFFFFF // Avoid negative IDs } + + 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)) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 614327ea6..20ff18494 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ xz = "1.12" commonsCompress = "1.28.0" spotless = "8.5.1" workManager = "2.11.2" +lifecycleProcess = "2.11.0" [libraries] okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } @@ -72,6 +73,7 @@ coreKtx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } playServicesGamesV2 = { module = "com.google.android.gms:play-services-games-v2", version.ref = "playGames" } xz = { module = "org.tukaani:xz", version.ref = "xz" } workRuntimeKtx = { module = "androidx.work:work-runtime-ktx", version.ref = "workManager" } +lifecycleProcess = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } From 4c4b8168c910d8538aec0598b2afb254f0bb8990 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Fri, 10 Jul 2026 14:18:45 +0200 Subject: [PATCH 10/19] Fix wrong flavors that were used in local tests. --- app/build.gradle | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c43cc82c8..615a579ac 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -161,18 +161,18 @@ android { flavorDimensions "branding" productFlavors { - /*standard { + standard { dimension "branding" - applicationId "com.winnative.test" - }*/ - debugTest1 { + applicationId "com.winnative.cmod" + } + ludashi { dimension "branding" - applicationId "com.winnative.test1" + applicationId "com.ludashi.benchmark" } - /*debugTest2 { + pubg { dimension "branding" - applicationId "com.winnative.test2" - }*/ + applicationId "com.tencent.ig" + } } ndkVersion '27.3.13750724' From 6c27d7c0797439da2ffed82bd806a9d74c07e8c3 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Fri, 10 Jul 2026 14:51:48 +0200 Subject: [PATCH 11/19] Cleaning. Removed unused code. --- app/src/main/runtime/system/LogManager.kt | 27 -- .../main/runtime/system/ProcessHelper.java | 11 - .../system/SessionKeepAliveService.java | 315 +----------------- .../main/shared/android/NotificationHelper.kt | 13 - 4 files changed, 2 insertions(+), 364 deletions(-) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index e9bbbe6e7..b835b664d 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -149,20 +149,6 @@ object LogManager { @JvmStatic fun getLogsDir(context: Context): File { - /*val baseDir = context.getExternalFilesDir(null) ?: context.filesDir - val dir = File(baseDir, "logs") - if (!dir.exists()) dir.mkdirs()*/ - - /*val prefs = PreferenceManager.getDefaultSharedPreferences(context) - val currentPath = resolvePathString(prefs.getString("winlator_path_uri", null), SettingsConfig.DEFAULT_WINLATOR_PATH, context) - - val dir = File(currentPath, "logs") - if (!dir.exists()) dir.mkdirs() - - Timber.tag(TAG).d("Winlator path: $ctx") - - return dir*/ - cachedLogsDir?.let { return it } val ctx = resolveContext(context) ?: return File(SettingsConfig.DEFAULT_WINLATOR_PATH, "logs").also { // No context available anywhere yet (init() never called and none @@ -542,21 +528,8 @@ object LogManager { command.add(it) } - // New with custom logcat filter eventWatchProcess = Runtime.getRuntime().exec(command.toTypedArray()) - // Old, with hardcoded filter - /*eventWatchProcess = Runtime.getRuntime().exec( - arrayOf( - "logcat", "-v", "threadtime", "-f", file.absolutePath, -// "ActivityManager:I", "lmkd:I", "OomAdjuster:I", "ActivityTaskManager:I", "Process:I", // For tracking OS killer. - // For tracking the annoying container-resume shut down bug. - "ActivityManager:I", "Process:I", "XConnectorEpoll:D", "ClientSocket:D", "XClientConnectionHandler:D", "Surface:I", "VkRenderer:I", - "*:S", // Silence -// "*:D", // Debug [Note: This filter drastically increases the chances that the container will close upon returning to it] - ), - )*/ - closeProcessStdin(eventWatchProcess) } catch (e: Exception) { // Timber.tag(TAG).e("Failed to start event watch: ${e.message}") diff --git a/app/src/main/runtime/system/ProcessHelper.java b/app/src/main/runtime/system/ProcessHelper.java index af7c54f95..91b53176a 100644 --- a/app/src/main/runtime/system/ProcessHelper.java +++ b/app/src/main/runtime/system/ProcessHelper.java @@ -246,19 +246,8 @@ private static boolean isCoreProcess(String normalizedData) { // Make the OS never OOM-kill the paused process if possible. public static void protectAllWineProcesses() { ArrayList processes = listRunningWineProcesses(); -// String actualAdj = ""; for (String process : processes) { - /*actualAdj = readProcFile("/proc/" + process + "/oom_score_adj"); - LogManager.log(OOM_TAG, "pid=" + process + - " beforeSet=" + (actualAdj != null ? actualAdj.trim() : "unreadable"));*/ - setOomScoreAdj(Integer.parseInt(process), OOM_SCORE_ADJ_PROTECT); - - // Check if the OOM Score is doing something, actually. - /*actualAdj = readProcFile("/proc/" + process + "/oom_score_adj"); - LogManager.log(OOM_TAG, - "pid=" + process + " requested=" + OOM_SCORE_ADJ_PROTECT - + " actual=" + (actualAdj != null ? actualAdj.trim() : "unreadable"));*/ } } diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index a770e2bb2..510caa60d 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -1,11 +1,7 @@ package com.winlator.cmod.runtime.system; -import android.app.ActivityManager; import android.app.KeyguardManager; import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; import android.app.Service; import android.content.BroadcastReceiver; import android.content.Context; @@ -21,10 +17,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; import androidx.preference.PreferenceManager; -import com.winlator.cmod.R; import com.winlator.cmod.app.shell.UnifiedActivity; import com.winlator.cmod.feature.stores.steam.utils.PrefManager; import com.winlator.cmod.runtime.display.XServerDisplayActivity; @@ -58,23 +52,18 @@ public class SessionKeepAliveService extends Service { private static volatile SessionKeepAliveService instance; private static final String TAG = "SessionKeepAlive"; - private static final String EXTRA_TAG = "tag"; + private static final String EXTRA_TAG = "SessionKeepAlive_debugTag"; private static final String ACTION_ENSURE_FOREGROUND = "com.winlator.cmod.action.ENSURE_FOREGROUND"; - private static final String CHANNEL_ID = "winnative_session_keepalive"; - private static final String ACTION_SESSION_START = "com.winlator.cmod.action.SESSION_START"; private static final String ACTION_SESSION_STOP = "com.winlator.cmod.action.SESSION_STOP"; private static final String ACTION_SESSION_PAUSE = "com.winlator.cmod.action.SESSION_PAUSE"; private static final String ACTION_SESSION_RESUME = "com.winlator.cmod.action.SESSION_RESUME"; private static final String ACTION_DL_START = "com.winlator.cmod.action.SESSION_DL_START"; private static final String ACTION_DL_STOP = "com.winlator.cmod.action.SESSION_DL_STOP"; - private static final String ACTION_UPDATE_COMPONENT = "com.winlator.cmod.action.UPDATE_COMPONENT"; - private static final String ACTION_REMOVE_COMPONENT = "com.winlator.cmod.action.REMOVE_COMPONENT"; public static final String COMPONENT_STEAM = "Steam"; -// public static final String COMPONENT_STEAM_FRIENDS = "Steam Friends"; public static final String COMPONENT_EPIC = "Epic"; public static final String COMPONENT_GOG = "GOG"; @@ -115,26 +104,6 @@ public class SessionKeepAliveService extends Service { // Tracks active components and their notification messages private static final Map activeComponents = new ConcurrentHashMap<>(); - // Component names constants - - - private final Handler protectionHandler = new Handler(Looper.getMainLooper()); - private final Runnable protectionRunnable = new Runnable() { - @Override - public void run() { - if (!sessionActive.get() || !isContainerPaused) return; - Timber.tag(TAG).d("Running periodic HEARTBEAT protection for a container session"); - new Thread(() -> { - try { -// ProcessHelper.protectAllWineProcesses(); - LogManager.log(TAG, "Heartbeat: Keeping container alive...", getApplicationContext()); - } catch (Exception e) { - LogManager.logE(TAG, "Periodic HEARTBEAT protection sweep failed", e, getApplicationContext()); - } - }, "SessionOomProtection").start(); - protectionHandler.postDelayed(this, 2 * 60 * 1000L); // Every 2 minutes - } - }; // =================================================================== // Container / game session lifecycle @@ -158,7 +127,6 @@ public static void startSession(Context ctx) { isActivityVisible = true; LogManager.log(TAG, "startSession", ctx); updateForegroundState(ctx); -// sendCommand(ctx, ACTION_SESSION_START, null); } public static void onPauseSession(Context ctx) { @@ -170,14 +138,12 @@ public static void onPauseSession(Context ctx) { isContainerPaused = true; isActivityVisible = false; LogManager.log(TAG, "onPauseSession", ctx); -// startProtectionHeartbeat(); if (instance != null) { instance.acquireWakeLock(); instance.runOomSweep(); instance.startHeartbeat(); } updateForegroundState(ctx); -// sendCommand(ctx, ACTION_SESSION_PAUSE, null); } public static void onResumeSession(Context ctx) { @@ -189,13 +155,11 @@ public static void onResumeSession(Context ctx) { isContainerPaused = false; isActivityVisible = true; LogManager.log(TAG, "onResumeSession", ctx); - // stopProtectionHeartbeat(); if (instance != null) { instance.stopHeartbeat(); instance.releaseWakeLock(); } updateForegroundState(ctx); -// sendCommand(ctx, ACTION_SESSION_RESUME, null); } public static void stopSession(Context ctx) { @@ -203,7 +167,6 @@ public static void stopSession(Context ctx) { if (!sessionActive.compareAndSet(true, false)) return; isContainerPaused = false; // LogManager.log(ctx, TAG, "stopSession"); - // stopProtectionHeartbeat(); if (instance != null) { instance.stopHeartbeat(); instance.releaseWakeLock(); @@ -211,27 +174,12 @@ public static void stopSession(Context ctx) { teardownEnvironmentAsync(); updateForegroundState(ctx); LogManager.log(TAG, "Stopping game session in keep-alive service. Request by: " + Objects.requireNonNull(ctx.getClass().getName()), ctx); -// sendCommand(ctx, ACTION_SESSION_STOP, null); } public static boolean isSessionActive() { return sessionActive.get(); } - // Possibly, this method is useless because it does not restart - // in the background unless another class calls this class. - private static void startProtectionHeartbeat() { - SessionKeepAliveService svc = instance; - if (svc == null) return; - svc.protectionHandler.removeCallbacks(svc.protectionRunnable); - svc.protectionHandler.post(svc.protectionRunnable); - } - - private static void stopProtectionHeartbeat() { - SessionKeepAliveService svc = instance; - if (svc != null) svc.protectionHandler.removeCallbacks(svc.protectionRunnable); - } - // Capture-then-null before handing off, so a second stopSession() call, // or a racing reader, can never observe a half-torn-down environment. private static void teardownEnvironmentAsync() { @@ -276,26 +224,6 @@ public static void startComponent(Context ctx, String componentName, String mess updateForegroundState(ctx); } - /*public static void startComponent(Context context, String componentName, String message) { - activeComponents.put(componentName, message != null ? message : ""); - - Intent intent = new Intent(context, SessionKeepAliveService.class); - intent.setAction(ACTION_UPDATE_COMPONENT); - intent.putExtra("component_name", componentName); - intent.putExtra("component_message", message); - - try { - context.startService(intent); - } catch (Exception e) { - // SILENT CATCH: This prevents the app from crashing. - // If it fails, it means the app is in background. - // The service will start correctly next time the app goes to foreground. - String logMsg = "BackgroundServiceStartNotAllowed: Could not start master service for " + componentName; - Log.w(TAG, logMsg); - LogManager.logWarn(context, TAG, logMsg, e); - } - }*/ - public static void stopComponent(Context ctx, String componentName) { if (ctx == null || componentName == null) return; if (activeComponents.remove(componentName) == null) return; @@ -303,24 +231,6 @@ public static void stopComponent(Context ctx, String componentName) { updateForegroundState(ctx); } - /*public static void stopComponent(Context context, String componentName) { - // 1. Update data - activeComponents.remove(componentName); - - // 2. ONLY send the intent if the service is ALREADY running. - // This avoids starting the service just to stop a component. - if (serviceRunning.get()) { - Intent intent = new Intent(context, SessionKeepAliveService.class); - intent.setAction(ACTION_REMOVE_COMPONENT); - intent.putExtra("component_name", componentName); - try { - context.startService(intent); - } catch (Exception e) { - Log.w(TAG, "Failed to send stop component to master service (App in background)"); - } - } - }*/ - public static boolean isAppInBackground() { return isAppInBackground; } public static boolean isDeviceLocked() { return isScreenLocked; } @@ -340,7 +250,6 @@ public static void startDownload(Context ctx, String tag) { if (added) { Timber.tag(TAG).d("startDownload: %s", key); updateForegroundState(ctx); -// sendCommand(ctx, ACTION_DL_START, key); } } @@ -352,7 +261,6 @@ public static void stopDownload(Context ctx, String tag) { if (removed) { Timber.tag(TAG).d("stopDownload: %s", key); updateForegroundState(ctx); -// sendCommand(ctx, ACTION_DL_STOP, key); } } @@ -363,11 +271,6 @@ public static void stopDownload(Context ctx, String tag) { private static boolean hasReason() { return sessionActive.get() || !activeDownloads.isEmpty() || !activeComponents.isEmpty() || (isAppVisible() && PrefManager.INSTANCE.getChatStayRunningOnExit()); - - /*if (sessionActive.get()) return true; - synchronized (activeDownloads) { - return !activeDownloads.isEmpty(); - }*/ } // Single chokepoint for every caller (session, components, downloads). @@ -552,216 +455,10 @@ public void onTimeout(int startId, int fstype) { // Stop the service and cleanup sessionActive.set(false); isContainerPaused = false; - // stopProtectionHeartbeat(); stopForegroundCompat(); stopSelf(); } - private static void sendCommand(Context ctx, String action, @Nullable String tag) { - Context app = ctx.getApplicationContext(); - Intent intent = new Intent(app, SessionKeepAliveService.class); - intent.setAction(action); - if (tag != null) intent.putExtra(EXTRA_TAG, tag); - try { - if (ACTION_SESSION_START.equals(action) || ACTION_DL_START.equals(action)) { - app.startForegroundService(intent); - } else { - app.startService(intent); - } - } catch (Exception e) { - // If starting the service fails, try starting it as a foreground service as a fallback. - app.startForegroundService(intent); - Timber.tag(TAG).w(e, "Failed to send command %s", action); - } - } - -/* @Override - public void onCreate() { - LogManager.logLastExitReasons(getApplicationContext()); - - super.onCreate(); -// generateNotificationId(); - instance = this; - - // Initialize the helper using the application context - notificationHelper = new NotificationHelper(getApplicationContext()); - notificationHelper.createNotificationChannel(); // Replace ensureChannel() method. - - // Keep the CPU alive to prevent OS from killing the process when the screen is off. - *//*PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); - if (pm != null) { - wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WinNative:KeepAlive"); - }*//* - - // Keep the Wi-Fi alive to prevent network interruptions. Useful for games that stream assets from the network or have online features. - *//*WifiManager wm = (WifiManager) getSystemService(WIFI_SERVICE); - if (wm != null) { - int lockType = WifiManager.WIFI_MODE_FULL; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - lockType = WifiManager.WIFI_MODE_FULL_HIGH_PERF; - } - wifiLock = wm.createWifiLock(lockType, "WinNative:WifiKeepAlive"); - }*//* - -// ensureChannel(); - }*/ - - /*@Override - public int onStartCommand(Intent intent, int flags, int startId) { - String action = intent != null ? intent.getAction() : null; - - if (ACTION_SESSION_START.equals(action)) { - sessionActive.set(true); - isContainerPaused = false; - } else if (ACTION_SESSION_PAUSE.equals(action)) { - isContainerPaused = true; - protectionHandler.removeCallbacks(protectionRunnable); - protectionHandler.post(protectionRunnable); - } else if (ACTION_SESSION_RESUME.equals(action)) { - isContainerPaused = false; - protectionHandler.removeCallbacks(protectionRunnable); - } else if (ACTION_UPDATE_COMPONENT.equals(action)) { - String name = intent.getStringExtra("component_name"); - String msg = intent.getStringExtra("component_message"); - if (name != null) activeComponents.put(name, msg); - } else if (ACTION_REMOVE_COMPONENT.equals(action)) { - String name = intent.getStringExtra("component_name"); - if (name != null) activeComponents.remove(name); - } else if (ACTION_SESSION_STOP.equals(action)) { - sessionActive.set(false); - isContainerPaused = false; - protectionHandler.removeCallbacks(protectionRunnable); - if (activeEnvironment != null) { - final XEnvironment env = activeEnvironment; - activeEnvironment = null; - activeXServer = null; - new Thread(() -> { - try { - env.stopEnvironmentComponents(); - } catch (Exception e) { - LogManager.logError(this, TAG, "Failed to stop environment components during session stop", e); - } - }, "XServerTeardown").start(); - } - } - - // Ensure wake lock, wifi lock and OOM adj are correct based on current state - *//*if (hasReason()) { -// if (wakeLock != null && !wakeLock.isHeld()) wakeLock.acquire(); -// if (wifiLock != null && !wifiLock.isHeld()) wifiLock.acquire(); -// ProcessHelper.setOomScoreAdj(android.os.Process.myPid(), -1000); - *//**//*new Thread(() -> { - try { - ProcessHelper.protectAllWineProcesses(); - } catch (Exception e) { - Log.e(TAG, "Failed to run initial OOM protection", e); - } - }, "InitialWineOomProtection").start();*//**//* - } else { -// if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); -// if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); -// ProcessHelper.setOomScoreAdj(android.os.Process.myPid(), 0); - }*//* - - if (hasReason()) { - // Always promote to foreground first so Android does not consider - // the start a violation (and so the notification reflects current - // reasons), even if the command immediately tells us to stop. - ensureForeground(); - serviceRunning.set(true); -// Log.d(TAG, "Service keep alive is running..."); - } - else { - LogManager.log(this, TAG, "No active reason; stopping keep-alive service"); - stopForegroundCompat(); - stopSelf(); - serviceRunning.set(false); - } - return START_NOT_STICKY; - }*/ - - /*private void ensureForeground() { -// Notification n = buildNotification(); - - boolean containerActive = sessionActive.get(); - // Only show Exit button if container is running AND app is in background - boolean showExit = containerActive && !isActivityVisible; - - // Determine target activity: Game screen if active, else Main menu - Class targetActivity = containerActive ? XServerDisplayActivity.class : UnifiedActivity.class; - - Notification n = notificationHelper.createForegroundNotification( - getNotificationContent(), - "WinNative", // Title - SessionKeepAliveService.class, // Service class for the 'Exit' action - showExit ? ACTION_SESSION_STOP : null, // Exit only for backgrounded container - targetActivity // Activity class for the 'Open' (notification tap) action - ); - notificationId = notificationHelper.generateNotificationId(this, NOTIFICATION_ID_NAME); - - try { - // Only call startForeground the first time. Use notify() for updates. - if (!serviceRunning.get()) { - *//*if (Build.VERSION.SDK_INT >= 34) { - startForeground(notificationId, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); - } - else *//*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(notificationId, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); - } - else { - startForeground(notificationId, n); - } - } - else { - // Standard notification update - notificationHelper.notify(notificationId, n); - } - - } catch (Exception e) { - LogManager.logWarn(this, TAG, "Failed to startForeground", e); - } - }*/ - - public void ensureChannel() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; - NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); - if (nm == null) return; - if (nm.getNotificationChannel(CHANNEL_ID) != null) return; - NotificationChannel channel = new NotificationChannel( - CHANNEL_ID, - "WinNative session keep-alive", - NotificationManager.IMPORTANCE_LOW); - channel.setDescription( - "Keeps WinNative running in the background so a paused game session or " - + "an active component download is not interrupted by screen lock."); - channel.setShowBadge(false); - channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); - nm.createNotificationChannel(channel); - } - - public Notification buildNotification() { - String content = getNotificationContent(); - - Intent openIntent = new Intent(this, XServerDisplayActivity.class); - openIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); - PendingIntent contentIntent = PendingIntent.getActivity( - this, - 0, - openIntent, - PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); - - return new NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle("WinNative") - .setContentText(content) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setOngoing(true) - .setShowWhen(false) - .setContentIntent(contentIntent) - .build(); - } - // =================================================================== // Cleaning methods // =================================================================== @@ -772,15 +469,13 @@ public void onTaskRemoved(Intent rootIntent) { LogManager.logI(TAG, "Task removed (user swipe). Tearing down session and exiting process.", this); resetLocalState(); - // stopProtectionHeartbeat(); performDefensiveCleanupAndExit(this); } @Override public void onDestroy() { - // stopProtectionHeartbeat(); -// if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); + if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); // if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); stopHeartbeat(); releaseWakeLock(); @@ -805,12 +500,6 @@ public IBinder onBind(Intent intent) { return null; } - /*public int generateNotificationId() { - // Generate a unique ID based on the package name to avoid conflicts with other forks/flavors. - String contextKey = getPackageName() + ".winnative.keepAlive"; - return notificationId = contextKey.hashCode() & 0x7FFFFFFF; // Avoid negative IDs - }*/ - // =================================================================== // Utility methods // =================================================================== diff --git a/app/src/main/shared/android/NotificationHelper.kt b/app/src/main/shared/android/NotificationHelper.kt index 56f97aa24..e2b949222 100644 --- a/app/src/main/shared/android/NotificationHelper.kt +++ b/app/src/main/shared/android/NotificationHelper.kt @@ -195,19 +195,6 @@ class NotificationHelper "Allows to display WinNative foreground notifications", false ) - - /*val channel = - NotificationChannel( - CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = "Allows to display WinNative foreground notifications" - setShowBadge(false) - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - } - - notificationManager.createNotificationChannel(channel)*/ } /** From c50ed847210019920fc78dcf7cafa09a71b1c5b9 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Fri, 10 Jul 2026 15:01:05 +0200 Subject: [PATCH 12/19] Fix: Uncommented the notificationHelper variable in SteamService (required for Steam Friends chat). --- app/src/main/feature/stores/steam/service/SteamService.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 6a507d80a..bc6c0df88 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -186,7 +186,7 @@ class SteamService : Service() { @Inject lateinit var downloadingAppInfoDao: DownloadingAppInfoDao -// private lateinit var notificationHelper: NotificationHelper + private lateinit var notificationHelper: NotificationHelper /*var notificationID = 1 var preferences: SharedPreferences? = null*/ /*private var STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID = -3 // Previus default: 3 @@ -7540,9 +7540,9 @@ class SteamService : Service() { super.onCreate() instance = this - /*notificationHelper = NotificationHelper(applicationContext) + notificationHelper = NotificationHelper(applicationContext) // Assing a unique value to this notifiaction ID - if (STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID < 0) { + /*if (STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID < 0) { STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID = notificationHelper.generateNotificationId(this, STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID_NAME) }*/ From 867ea398aac63f5c3962877af0cbc03e264b7b8f Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Fri, 10 Jul 2026 19:28:26 +0200 Subject: [PATCH 13/19] Disabled the Exit button in the notification to close the container/game while in background. It causes too many problems, including: - An ANR crash if you return to the app after pressing Exit. - The container restarts automatically when you return to the app after pressing Exit. --- .../display/XServerDisplayActivity.java | 26 ++++++++++++++++--- .../system/SessionKeepAliveService.java | 6 ++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 52ffb50e8..8ba7d1c58 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -3049,22 +3049,42 @@ private void closeAfterSessionExit() { return; } + // ToDo: Find a way to avoid stealing focus from the user without causing crashes, ANRs, or session re-starts when opening the app again + // after use 'Exit' in the notification. // If the app is already in the background (e.g. the user pressed Exit // from the notification while using another app), just finish this // Activity without starting UnifiedActivity — doing so would bring // WinNative in front of whatever the user was doing. - if (SessionKeepAliveService.isAppVisible()) { - finish(); + if (SessionKeepAliveService.isAppInBackground() && SessionKeepAliveService.exitingFromNotification) { + // Navigate to the main screen to guarantee a clean back stack regardless + // of how this activity was originally launched, then immediately push the task + // back so we don't steal the foreground. + startUnifiedActivity(); + // Suppress the transition animation — without this, there is a brief + // visible flash of UnifiedActivity before the task goes to the back. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0); + } else { + overridePendingTransition(0, 0); + } + // Send the task to the back *without* finishing — CLEAR_TOP will finish + // XServerDisplayActivity and surface UnifiedActivity naturally, all + // while the task stays behind whatever the user was doing. + moveTaskToBack(true); return; } returnToUnifiedActivity(); } - private void returnToUnifiedActivity() { + private void startUnifiedActivity() { Intent intent = new Intent(this, UnifiedActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); startActivity(intent); + } + + private void returnToUnifiedActivity() { + startUnifiedActivity(); finish(); } diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 510caa60d..8fa8417e2 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -82,6 +82,7 @@ public class SessionKeepAliveService extends Service { // Both are updated from the main thread; volatile is sufficient for reads. private static volatile boolean isAppInBackground = false; private static volatile boolean isScreenLocked = false; + public static volatile boolean exitingFromNotification = false; private BroadcastReceiver screenStateReceiver; private PowerManager.WakeLock wakeLock; @@ -125,6 +126,7 @@ public static void startSession(Context ctx) { sessionActive.set(true); isContainerPaused = false; isActivityVisible = true; + exitingFromNotification = false; LogManager.log(TAG, "startSession", ctx); updateForegroundState(ctx); } @@ -367,6 +369,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { // Handle the Exit button from the notification if (ACTION_SESSION_STOP.equals(action)) { + exitingFromNotification = true; boolean chatStayAlive = PrefManager.INSTANCE.getChatStayRunningOnExit(); // If a game is running, and we want to keep chat alive, only stop the session. @@ -400,7 +403,8 @@ public int onStartCommand(Intent intent, int flags, int startId) { private void ensureForeground() { boolean containerActive = sessionActive.get(); // Only show Exit button if app is in background AND container is running or user wants to keep steam chat alive. - boolean showExit = isAppVisible() && (containerActive || PrefManager.INSTANCE.getChatStayRunningOnExit()); +// boolean showExit = isAppVisible() && (containerActive || PrefManager.INSTANCE.getChatStayRunningOnExit()); // Disabled because container "Exit" causes too much issues. + boolean showExit = isAppVisible() && PrefManager.INSTANCE.getChatStayRunningOnExit(); // Determine target activity: Game screen if active, else Main menu Class targetActivity = containerActive ? XServerDisplayActivity.class : UnifiedActivity.class; From b4b471d88640b28b301d31ba263ace7daa6aed10 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 11 Jul 2026 10:20:21 +0200 Subject: [PATCH 14/19] LogManager: Included system tag list and hardcoded tag list in UI Log Filter. - Now system tags previously hardcoded in the recorded logs appears as custom tags in the UI, so they can be selected/unselected. - Added a new custom tag list in LogManager for tags outside the scope of the CollectorLogTag module. - For example: secondary tags in classes, hardcoded tags in logs, or tags from `.c` classes. --- app/src/main/runtime/system/LogManager.kt | 51 ++++++++++++++++--- .../main/runtime/system/ProcessHelper.java | 2 +- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index b835b664d..2eed81226 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -38,8 +38,27 @@ object LogManager { // Fixed diagnostic baseline always present in an event-watch capture, // independent of the app-tag filter — these are system components, not // app classes, so they don't belong in the same selectable list. - private val BASELINE_SYSTEM_TAGS = listOf( - "ActivityManager:I", "lmkd:I", "OomAdjuster:I", "ActivityTaskManager:I", "Process:I", + // Key = display name / selectable tag; value = logcat priority level. + // Stored as a map so the priority suffix is only applied when building + // the filterspec, not shown in the UI. + private val BASELINE_SYSTEM_TAGS: Map = linkedMapOf( + "ActivityManager" to "I", + "ActivityTaskManager" to "I", + "OomAdjuster" to "I", + "lmkd" to "I", + "Process" to "I", + ) + + // Developer-curated tags that always appear in the selectable list, + // supplementing GeneratedLogTags (auto-discovered via Gradle) and + // user-added custom tags. Add entries here for tags that matter for + // debugging but may not be auto-discovered (e.g. tags in native code + // or tags used only in rarely-executed paths). + private val DEVELOPER_TAGS: Set = setOf( + "WinlatorLifecycle", + "WineOomProtect", + "GuestProgramLauncherComponent", + "XServerLeakCheck", ) private const val PREF_ENABLE_APP_DEBUG = "enable_app_debug" @@ -232,7 +251,9 @@ object LogManager { /** Union of build-time-discovered tags and user-added custom ones, sorted for display. */ @JvmStatic fun getAllKnownTags(): List = - (GeneratedLogTags.TAGS + cachedCustomTags).distinct().sorted() + (GeneratedLogTags.TAGS + DEVELOPER_TAGS + BASELINE_SYSTEM_TAGS.keys + cachedCustomTags) + .distinct() + .sorted() @JvmStatic fun addCustomTag(context: Context, tag: String) { @@ -549,18 +570,36 @@ object LogManager { private fun buildLogcatFilterSpecArgs(): List { val spec = mutableListOf() - spec.addAll(BASELINE_SYSTEM_TAGS) + val selectedBaseline = BASELINE_SYSTEM_TAGS.keys.filter { it in cachedSelectedTags }.toSet() + val selectedApp = cachedSelectedTags - BASELINE_SYSTEM_TAGS.keys + + // System tags — always included in ALL mode; otherwise filtered like app tags. + when (cachedTagFilterMode) { + TagFilterMode.ALL -> + BASELINE_SYSTEM_TAGS.forEach { (tag, priority) -> spec.add("$tag:$priority") } + TagFilterMode.INCLUDE -> + selectedBaseline.forEach { tag -> + spec.add("$tag:${BASELINE_SYSTEM_TAGS[tag]}") + } + TagFilterMode.EXCLUDE -> + BASELINE_SYSTEM_TAGS.forEach { (tag, priority) -> + if (tag !in selectedBaseline) spec.add("$tag:$priority") + } + } + + // App tags when (cachedTagFilterMode) { TagFilterMode.ALL -> spec.add("*:D") TagFilterMode.EXCLUDE -> { spec.add("*:D") - cachedSelectedTags.forEach { spec.add("$it:S") } + selectedApp.forEach { spec.add("$it:S") } } TagFilterMode.INCLUDE -> { - cachedSelectedTags.forEach { spec.add("$it:D") } + selectedApp.forEach { spec.add("$it:D") } spec.add("*:S") } } + return spec } diff --git a/app/src/main/runtime/system/ProcessHelper.java b/app/src/main/runtime/system/ProcessHelper.java index b98dd6e19..3612d3adc 100644 --- a/app/src/main/runtime/system/ProcessHelper.java +++ b/app/src/main/runtime/system/ProcessHelper.java @@ -84,7 +84,7 @@ public static BackgroundPauseMode fromPrefValue(String value) { private static volatile BackgroundPauseMode backgroundPauseMode = BackgroundPauseMode.GAME_ONLY; private static volatile int registeredGamePid = -1; - private static final String OOM_TAG = "WNOomProtect"; + private static final String OOM_TAG = "WineOomProtect"; public static native int reapDeadChildrenNow(); From 40aab665517411c9e58830c976b28180dcbec8c3 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 11 Jul 2026 14:56:04 +0200 Subject: [PATCH 15/19] feat: Android system tags are now highlighted in yellow when selected in the Tag Filter. --- .../main/feature/settings/debug/DebugScreen.kt | 14 +++++++++++--- app/src/main/runtime/system/LogManager.kt | 18 ++++++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index 2e26fd50d..ba698db72 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -1006,6 +1006,7 @@ private fun MultiSelectDialog( onDismiss: () -> Unit, onConfirm: (List) -> Unit, extraContent: (@Composable (selected: Set, onToggle: (String) -> Unit) -> Unit)? = null, + systemTags: Set = emptySet(), ) { val selected = remember(initiallySelected) { @@ -1111,6 +1112,7 @@ private fun MultiSelectDialog( ChannelGrid( options = options, + systemTags = systemTags, selected = selected.value, gridState = gridState, onViewport = { top, h -> @@ -1203,6 +1205,7 @@ private fun LogTagFilterDialog( MultiSelectDialog( title = stringResource(R.string.settings_debug_log_tag_filter_channel), options = allOptions, + systemTags = remember { LogManager.getSystemTags() }, initiallySelected = initiallySelected, onDismiss = onDismiss, onConfirm = { selected -> onConfirm(mode, selected) }, @@ -1366,6 +1369,7 @@ private fun ColumnScope.ChannelGrid( gridState: androidx.compose.foundation.lazy.grid.LazyGridState, onViewport: (Float, Int) -> Unit, onToggle: (String) -> Unit, + systemTags: Set = emptySet(), // new — tags that render in a distinct color ) { if (options.isEmpty()) { Text( @@ -1390,11 +1394,14 @@ private fun ColumnScope.ChannelGrid( verticalArrangement = Arrangement.spacedBy(8.dp), ) { itemsIndexed(options) { index, channel -> + val isSystem = channel in systemTags + val chipAccent = if (isSystem) Color(0xFFFFCC00) else Accent SelectableChannelChip( label = channel, isSelected = channel in selected, isEntry = index == 0, onToggle = { onToggle(channel) }, + tint = chipAccent, ) } } @@ -1406,10 +1413,11 @@ private fun SelectableChannelChip( isSelected: Boolean, isEntry: Boolean, onToggle: () -> Unit, + tint: Color = Accent, ) { - val bg = if (isSelected) Accent.copy(alpha = 0.18f) else IconBoxBg - val borderColor = if (isSelected) Accent.copy(alpha = 0.55f) else CardBorder - val textColor = if (isSelected) Accent else TextPrimary + val bg = if (isSelected) tint.copy(alpha = 0.18f) else IconBoxBg + val borderColor = if (isSelected) tint.copy(alpha = 0.55f) else CardBorder + val textColor = if (isSelected) tint else TextPrimary Box( modifier = Modifier diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 2eed81226..f7236360b 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -293,6 +293,9 @@ object LogManager { @JvmStatic fun getTagFilterMode(): TagFilterMode = cachedTagFilterMode + @JvmStatic + fun getSystemTags(): Set = BASELINE_SYSTEM_TAGS.keys.toSet() + /** Transient only — never written to SharedPreferences. Pass null/blank to clear. */ @JvmStatic fun setManualTextFilter(text: String?) { @@ -575,8 +578,19 @@ object LogManager { // System tags — always included in ALL mode; otherwise filtered like app tags. when (cachedTagFilterMode) { - TagFilterMode.ALL -> - BASELINE_SYSTEM_TAGS.forEach { (tag, priority) -> spec.add("$tag:$priority") } + TagFilterMode.ALL -> { + // In ALL mode, include ALL system tags that haven't been explicitly + // deselected. An empty selection means "show everything" (default), + // so only suppress a system tag if it was explicitly unchecked. + val excluded = BASELINE_SYSTEM_TAGS.keys - selectedBaseline + BASELINE_SYSTEM_TAGS.forEach { (tag, priority) -> + if (tag !in excluded || cachedSelectedTags.isEmpty()) { + spec.add("$tag:$priority") + } + } + spec.add("*:D") + excluded.forEach { spec.add("$it:S") } + } TagFilterMode.INCLUDE -> selectedBaseline.forEach { tag -> spec.add("$tag:${BASELINE_SYSTEM_TAGS[tag]}") From af0f1b434f79aaa55a5c670d3be515607b6e1c77 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 11 Jul 2026 19:14:33 +0200 Subject: [PATCH 16/19] Epic and GOG services updated to work with the master foreground service. - Epic and GOG services are now regular services protected by the foreground service of SessionKeepAliveService. --- .../stores/epic/service/EpicService.kt | 32 ++++++++++--------- .../feature/stores/gog/service/GOGService.kt | 31 ++++++++++-------- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/app/src/main/feature/stores/epic/service/EpicService.kt b/app/src/main/feature/stores/epic/service/EpicService.kt index aad8ad7d5..2e83e5d74 100644 --- a/app/src/main/feature/stores/epic/service/EpicService.kt +++ b/app/src/main/feature/stores/epic/service/EpicService.kt @@ -39,11 +39,11 @@ import android.content.pm.ServiceInfo import android.os.Build import com.winlator.cmod.shared.android.NotificationHelper.Companion.ACTION_EXIT -// Foreground service facade for Epic auth, library sync, downloads, and cloud saves. +// Service facade for Epic auth, library sync, downloads, and cloud saves. @AndroidEntryPoint class EpicService : Service() { - private lateinit var notificationHelper: NotificationHelper - var notificationID = 1 + /*private lateinit var notificationHelper: NotificationHelper + var notificationID = 1*/ @Inject lateinit var epicManager: EpicManager @@ -93,7 +93,9 @@ class EpicService : Service() { Timber.tag("EPIC").i("[EpicService] First-time start - starting service with initial sync") val intent = Intent(context, EpicService::class.java) intent.action = ACTION_SYNC_LIBRARY - context.startForegroundService(intent) + + // Just start as a normal service. KeepAliveService should protect this. + context.startService(intent) return } @@ -108,25 +110,27 @@ class EpicService : Service() { val remainingMinutes = (SYNC_THROTTLE_MILLIS - timeSinceLastSync) / 1000 / 60 Timber.tag("EPIC").i("Starting service without sync - throttled (${remainingMinutes}min remaining)") } - context.startForegroundService(intent) + + // Just start as a normal service. KeepAliveService should protect this. + context.startService(intent) } fun triggerLibrarySync(context: Context) { Timber.tag("EPIC").i("Triggering manual library sync (bypasses throttle)") val intent = Intent(context, EpicService::class.java) intent.action = ACTION_MANUAL_SYNC - context.startForegroundService(intent) + context.startService(intent) } fun stop() { instance?.let { service -> runCatching { - service.stopForeground(Service.STOP_FOREGROUND_REMOVE) + SessionKeepAliveService.stopComponent(service, SessionKeepAliveService.COMPONENT_EPIC) }.onFailure { Timber.w(it, "Failed to remove EpicService foreground state during shutdown") } - runCatching { + /*runCatching { if (service::notificationHelper.isInitialized) service.notificationHelper.cancel(service.notificationID) - }.onFailure { Timber.w(it, "Failed to cancel EpicService notification during shutdown") } + }.onFailure { Timber.w(it, "Failed to cancel EpicService notification during shutdown") }*/ service.stopSelf() } } @@ -1267,7 +1271,7 @@ class EpicService : Service() { instance = this Timber.tag("Epic").i("[EpicService] Service created") - notificationHelper = NotificationHelper(applicationContext) +// notificationHelper = NotificationHelper(applicationContext) PluviaApp.events.on(onEndProcess) DownloadCoordinator.registerDispatcher(DownloadRecord.STORE_EPIC, coordinatorDispatcher) @@ -1280,9 +1284,7 @@ class EpicService : Service() { ): Int { Timber.tag("EPIC").d("onStartCommand() - action: ${intent?.action}") - val instance = getInstance() - val notification = notificationHelper.createForegroundNotification("Connected") - startForeground(1, notification) + SessionKeepAliveService.startComponent(this, SessionKeepAliveService.COMPONENT_EPIC, "Connected") val shouldSync = when (intent?.action) { @@ -1425,14 +1427,14 @@ class EpicService : Service() { } scope.cancel() - stopForeground(STOP_FOREGROUND_REMOVE) - notificationHelper.cancel(notificationID) + SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_EPIC) instance = null } override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) Timber.tag("EPIC").i("Task removed; stopping managed app services") + SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_EPIC) AppTerminationHelper.stopManagedServices(applicationContext, "epic_task_removed") } diff --git a/app/src/main/feature/stores/gog/service/GOGService.kt b/app/src/main/feature/stores/gog/service/GOGService.kt index 168b97af8..34db209a5 100644 --- a/app/src/main/feature/stores/gog/service/GOGService.kt +++ b/app/src/main/feature/stores/gog/service/GOGService.kt @@ -38,12 +38,12 @@ import android.content.pm.ServiceInfo import android.os.Build import com.winlator.cmod.shared.android.NotificationHelper.Companion.ACTION_EXIT -// Foreground service facade for GOG auth, library sync, downloads, and cloud saves. +// Service facade for GOG auth, library sync, downloads, and cloud saves. @AndroidEntryPoint class GOGService : Service() { - private lateinit var notificationHelper: NotificationHelper - var notificationID = 1 + /*private lateinit var notificationHelper: NotificationHelper + var notificationID = 1*/ @Inject lateinit var gogManager: GOGManager @@ -98,7 +98,9 @@ class GOGService : Service() { Timber.i("[GOGService] First-time start - starting service with initial sync") val intent = Intent(context, GOGService::class.java) intent.action = ACTION_SYNC_LIBRARY - context.startForegroundService(intent) + + // Just start as a normal service. KeepAliveService should protect this. + context.startService(intent) return } @@ -113,25 +115,27 @@ class GOGService : Service() { val remainingMinutes = (SYNC_THROTTLE_MILLIS - timeSinceLastSync) / 1000 / 60 Timber.d("[GOGService] Starting service without sync - throttled (${remainingMinutes}min remaining)") } - context.startForegroundService(intent) + + // Just start as a normal service. KeepAliveService should protect this. + context.startService(intent) } fun triggerLibrarySync(context: Context) { Timber.i("[GOGService] Triggering manual library sync (bypasses throttle)") val intent = Intent(context, GOGService::class.java) intent.action = ACTION_MANUAL_SYNC - context.startForegroundService(intent) + context.startService(intent) } fun stop() { instance?.let { service -> runCatching { - service.stopForeground(Service.STOP_FOREGROUND_REMOVE) + SessionKeepAliveService.stopComponent(service, SessionKeepAliveService.COMPONENT_GOG) }.onFailure { Timber.w(it, "Failed to remove GOGService foreground state during shutdown") } - runCatching { + /*runCatching { if (service::notificationHelper.isInitialized) service.notificationHelper.cancel(service.notificationID) - }.onFailure { Timber.w(it, "Failed to cancel GOGService notification during shutdown") } + }.onFailure { Timber.w(it, "Failed to cancel GOGService notification during shutdown") }*/ service.stopSelf() } } @@ -1581,7 +1585,7 @@ class GOGService : Service() { super.onCreate() instance = this - notificationHelper = NotificationHelper(applicationContext) +// notificationHelper = NotificationHelper(applicationContext) PluviaApp.events.on(onEndProcess) DownloadCoordinator.registerDispatcher(DownloadRecord.STORE_GOG, coordinatorDispatcher) @@ -1594,8 +1598,7 @@ class GOGService : Service() { ): Int { Timber.d("[GOGService] onStartCommand() - action: ${intent?.action}") - val notification = notificationHelper.createForegroundNotification("Connected") - startForeground(1, notification) + SessionKeepAliveService.startComponent(this, SessionKeepAliveService.COMPONENT_GOG, "Connected") val shouldSync = when (intent?.action) { @@ -1670,14 +1673,14 @@ class GOGService : Service() { setSyncInProgress(false) scope.cancel() - stopForeground(STOP_FOREGROUND_REMOVE) - notificationHelper.cancel(notificationID) + SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_GOG) instance = null } override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) Timber.i("[GOGService] Task removed; stopping managed app services") + SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_GOG) AppTerminationHelper.stopManagedServices(applicationContext, "gog_task_removed") } From c626749231f7af89ddb143ca7f7b3f98d7c03f67 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 11 Jul 2026 19:27:20 +0200 Subject: [PATCH 17/19] Updated store services notification. - Updated the message for the active stores service so that it follows the specified order and correctly separates store names with commas and "and". - Fixed a bug that caused Steam to lose foreground protection when the app was sent to the background. --- .../stores/steam/service/SteamService.kt | 5 --- .../system/SessionKeepAliveService.java | 45 ++++++++++++++++++- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 477eaf2f8..d1f7a958b 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -7853,11 +7853,6 @@ class SteamService : Service() { runCatching { notificationHelper.cancel() } .onFailure { Timber.w(it, "Failed to cancel SteamService notification on background suspend") } }*/ - // Background persistance updated code - scope.launch(Dispatchers.Main) { - runCatching { SessionKeepAliveService.stopComponent(applicationContext, SessionKeepAliveService.COMPONENT_STEAM) } - .onFailure { Timber.w(it, "Failed to remove SteamService foreground state on background suspend") } - } // ToDo end. return true } diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 8fa8417e2..4a9c0a26d 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -26,6 +26,7 @@ import com.winlator.cmod.runtime.display.xserver.XServer; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -594,10 +595,50 @@ private static String getNotificationContent() { // 4. LOW PRIORITY: Active store services if (!activeComponents.isEmpty()) { + // Define the priority order + List priority = Arrays.asList(COMPONENT_STEAM, COMPONENT_EPIC, COMPONENT_GOG); List names = new ArrayList<>(activeComponents.keySet()); - return names.size() == 1 + names.sort((a, b) -> { + int idxA = priority.indexOf(a); + int idxB = priority.indexOf(b); + if (idxA != -1 && idxB != -1) return Integer.compare(idxA, idxB); + if (idxA != -1) return -1; + if (idxB != -1) return 1; + return a.compareTo(b); + }); + + String joinedNames = names.get(0); + int size = names.size(); + + switch (size) { + case 0: + break; + case 1: + joinedNames = names.get(0); + break; + case 2: + joinedNames = names.get(0) + " and " + names.get(1); + break; + default: + // Future-proof: "A, B, and C" + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < size; i++) { + sb.append(names.get(i)); + if (i < size - 2) { + sb.append(", "); + } else if (i == size - 2) { + sb.append(", and "); + } + } + joinedNames = sb.toString(); + } + + /*return names.size() == 1 ? names.get(0) + " service is active" - : String.join(" and ", names) + " services are active"; + : String.join(" and ", names) + " services are active";*/ + + String suffix = (size == 1) ? " service is active" : " services are active"; + return joinedNames + suffix; } return "WinNative is running in the background"; } From 5ef2d68b532ca46b4ffa354051b924ade9d608c2 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 11 Jul 2026 20:04:15 +0200 Subject: [PATCH 18/19] Optimized crash trace report in LogManager. - The size and processing load of crash reports has been reduced considerably. - Examples with ANR crashes: - From 280 KB to 16.5 KB. - From 500 KB to 22 KB. --- app/src/main/runtime/system/LogManager.kt | 160 +++++++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index f7236360b..9ffcc1dee 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -671,12 +671,14 @@ object LogManager { try { info.traceInputStream?.use { input -> + val rawTrace = input.bufferedReader().readText() + val summary = summarizeTrace(rawTrace, info.reason) appendLine( ctx, CRASH_FILE, "I/$TAG", "=== Historical $type Detected ===\n" + "PID: ${info.pid} | Timestamp: ${Date(info.timestamp)}\n" + "Description: ${info.description}\n" + - "Trace Output:\n${input.bufferedReader().readText()}\n" + + "Trace Summary:\n$summary\n" + "=== End $type Report ===" ) } ?: run { @@ -732,4 +734,160 @@ object LogManager { Timber.e(e, "Failed to log crash") } } + + private fun summarizeTrace(rawTrace: String, reason: Int): String { + return when (reason) { + ApplicationExitInfo.REASON_ANR -> summarizeAnrTrace(rawTrace) + ApplicationExitInfo.REASON_CRASH -> summarizeJavaCrashTrace(rawTrace) + ApplicationExitInfo.REASON_CRASH_NATIVE -> summarizeNativeCrashTrace(rawTrace) + else -> rawTrace.lines().take(50).joinToString("\n") // unknown: keep first 50 lines + } + } + + /** + * ANR traces contain memory stats, CriticalEventLog boilerplate, and hundreds + * of sysTid lines for threads that aren't relevant to the block. Keep only: + * - Subject line (the dispatch timeout description) + * - Waiting Channels block (which thread is stuck and in what kernel call) + * - Any "main" thread or "Binder" thread lines that show who holds the lock + */ + private fun summarizeAnrTrace(raw: String): String { + val lines = raw.lines() + val out = StringBuilder() + var inWaitingChannels = false + var inJavaStack = false + + for (line in lines) { + when { + // Always keep the subject — it describes the actual timeout + line.startsWith("Subject:") -> { + out.appendLine(line) + } + // Start of the waiting-channels block + line.contains("----- Waiting Channels:") -> { + inWaitingChannels = true + out.appendLine(line) + } + // End of waiting-channels block + inWaitingChannels && line.startsWith("----- end") -> { + out.appendLine(line) + inWaitingChannels = false + } + // Keep all lines inside the waiting-channels block — + // sysTid entries show which kernel call each thread is stuck in, + // which is the key diagnostic for an ANR. + inWaitingChannels -> { + out.appendLine(line) + } + // Java stack section may also appear in ANR traces + line.startsWith("----- pid") && line.contains("at") -> { + inJavaStack = true + out.appendLine(line) + } + inJavaStack && line.startsWith("----- end") -> { + out.appendLine(line) + inJavaStack = false + } + inJavaStack && (line.contains("\"main\"") || line.contains("BLOCKED") + || line.contains("at com.winnative") || line.contains("at com.winlator")) -> { + out.appendLine(line) + } + // Skip: RSS stats, VmSwap, CriticalEventLog metadata, libdebuggerd lines + } + } + + return out.toString().trimEnd().ifEmpty { + // Fallback: if the format changed and nothing matched, return first 30 lines + lines.take(30).joinToString("\n") + } + } + + /** + * Java crash traces: keep the exception class, message, and the first + * meaningful stack frames (app code only — skip framework/runtime frames). + */ + private fun summarizeJavaCrashTrace(raw: String): String { + val lines = raw.lines() + val out = StringBuilder() + var inStack = false + var appFrameCount = 0 + val maxAppFrames = 20 + + for (line in lines) { + when { + // Exception declaration line (e.g. "java.lang.NullPointerException: ...") + !inStack && (line.trimStart().startsWith("java.") || + line.trimStart().startsWith("kotlin.") || + line.trimStart().startsWith("android.") && line.contains("Exception")) -> { + inStack = true + out.appendLine(line) + } + inStack && line.trimStart().startsWith("at ") -> { + val isAppFrame = line.contains("com.winnative") || line.contains("com.winlator") + if (isAppFrame && appFrameCount < maxAppFrames) { + out.appendLine(line) + appFrameCount++ + } else if (appFrameCount == 0) { + // Haven't found any app frames yet — keep first few framework frames + // so the trace isn't completely empty if the crash is in a system call + if (out.lines().count { it.trimStart().startsWith("at ") } < 5) { + out.appendLine(line) + } + } + } + inStack && line.trimStart().startsWith("Caused by:") -> { + out.appendLine(line) + appFrameCount = 0 // reset per cause + } + inStack && line.isBlank() -> { + inStack = false + } + } + } + + return out.toString().trimEnd().ifEmpty { lines.take(30).joinToString("\n") } + } + + /** + * Native crash traces: keep signal info, fault address, and the backtrace + * frames. Skip register dumps (x0-x29 etc.) and memory maps — registers + * are unreadable without a debugger and maps are huge. + */ + private fun summarizeNativeCrashTrace(raw: String): String { + val lines = raw.lines() + val out = StringBuilder() + var inBacktrace = false + var inMemoryMap = false + + for (line in lines) { + when { + // Memory map section is always last and always noise + line.contains("memory map") || line.contains("memory near") -> { + inMemoryMap = true + } + inMemoryMap -> { /* skip */ } + + // Signal and fault address — the "what crashed" summary + line.startsWith("signal ") || line.startsWith("Abort message:") || + line.contains("fault addr") -> { + out.appendLine(line) + } + // Backtrace section header + line.trimStart().startsWith("backtrace:") -> { + inBacktrace = true + out.appendLine(line) + } + // Backtrace frames + inBacktrace && line.trimStart().startsWith("#") -> { + out.appendLine(line) + } + inBacktrace && line.isBlank() -> { + inBacktrace = false + } + // Skip: register dumps (lines like " x0 0000..." or " lr ...") + } + } + + return out.toString().trimEnd().ifEmpty { lines.take(30).joinToString("\n") } + } } From d8b0f9de88d6c7766669dedcb5a01976b2b7cf4e Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 11 Jul 2026 20:52:32 +0200 Subject: [PATCH 19/19] =?UTF-8?q?Enable=20WakeLock=20by=20default=20on=20A?= =?UTF-8?q?ndroid=2016+=20if=20it=20hasn=E2=80=99t=20been=20explicitly=20d?= =?UTF-8?q?isabled=20by=20the=20user.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is more user-friendly for new or inexperienced users. --- app/src/main/feature/setup/SetupWizardActivity.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/feature/setup/SetupWizardActivity.kt b/app/src/main/feature/setup/SetupWizardActivity.kt index e49edaa81..70eb4ffb3 100644 --- a/app/src/main/feature/setup/SetupWizardActivity.kt +++ b/app/src/main/feature/setup/SetupWizardActivity.kt @@ -139,6 +139,7 @@ import java.io.File import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin +import androidx.core.content.edit private data class Particle( val x: Float, @@ -591,7 +592,7 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { notifDenied.value = !granted if (granted) { backgroundSessionEnabled.value = true - prefs(this).edit().putBoolean("enable_background_session", true).apply() + prefs(this).edit { putBoolean("enable_background_session", true) } } else if (Build.VERSION.SDK_INT >= 33 && !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) ) { @@ -889,6 +890,11 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { storageGranted.value = hasStoragePermission() notifGranted.value = hasNotificationPermissionSilently() backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", true) + if (Build.VERSION.SDK_INT > 36) { + if (!prefs(this).contains("enable_background_wakelock")) { // If wakeLock preference isn't saved, enable it by default on Android 16+. + prefs(this).edit { putBoolean("enable_background_wakelock", true) } + } + } refreshWizardState() loadAdvancedProfiles()