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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] =?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() From 6017a74743dd68eeec5977531b375ac3034d0219 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 11 Jul 2026 22:11:33 +0200 Subject: [PATCH 20/35] Hide lastExitReason toggle in debug settings and collapsed Heart beat frequency in other settings. - lastExitReason toggle has been hided for devices with Android 10 or lower (it doesn't work in those versions). - Collapse the HeartBeat option to keep the interface as lean as possible. --- .../feature/settings/debug/DebugScreen.kt | 17 ++- .../settings/other/OtherSettingsScreen.kt | 142 +++++++++++------- 2 files changed, 99 insertions(+), 60 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index ba698db72..539efd400 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -1,4 +1,5 @@ package com.winlator.cmod.feature.settings +import android.os.Build import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedVisibility @@ -307,13 +308,15 @@ fun DebugScreen( onCheckedChange = onAppDebugChanged, ) - 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, - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // This log only works on API 30+ (Android 11+) + 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, + ) + } SettingsToggleCard( title = stringResource(R.string.settings_debug_crash_log_title), diff --git a/app/src/main/feature/settings/other/OtherSettingsScreen.kt b/app/src/main/feature/settings/other/OtherSettingsScreen.kt index 795aaadea..d2f2c2968 100644 --- a/app/src/main/feature/settings/other/OtherSettingsScreen.kt +++ b/app/src/main/feature/settings/other/OtherSettingsScreen.kt @@ -1248,6 +1248,8 @@ private fun HeartbeatFrequencyCard( currentFrequency: Int, onFrequencyChanged: (Int) -> Unit, ) { + // Tracks expansion. False = collapsed by default. + var expanded by remember { mutableStateOf(false) } // 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 @@ -1275,7 +1277,19 @@ private fun HeartbeatFrequencyCard( .fillMaxWidth() .padding(horizontal = 14.dp, vertical = 12.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier + .fillMaxWidth() + .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( modifier = Modifier .size(34.dp) @@ -1298,63 +1312,85 @@ private fun HeartbeatFrequencyCard( fontSize = 14.sp, fontWeight = FontWeight.Medium, ) - Text( - text = stringResource(R.string.settings_other_bg_heartbeat_subtitle), - color = TextSecondary, - fontSize = 11.sp, - ) + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Text( + text = stringResource(R.string.settings_other_bg_heartbeat_subtitle), + color = TextSecondary, + fontSize = 11.sp, + ) + } } + // Chevron icon + Icon( + imageVector = if (expanded) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(20.dp) + ) } - 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() - .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 + // Collapsible content (Input + Supporting Text) + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column { + 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() + .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. + 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, + ) } - onFrequencyChanged(next) }, - highlightColor = NavHighlight, ) - .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, - ) - } - }, - ) + } + } } } } From a4eea2bc8817b82ea8cbcbdda985490c2d6f3b57 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sun, 12 Jul 2026 10:36:12 +0200 Subject: [PATCH 21/35] LogManager: Optimized exitReasons and Crash logs avoiding duplicated reports. - Modified DebugFragment.kt and XServerDisplayActivity to correctly reset the keys, used to avoid duplicates, when the log files are deleted. --- .../feature/settings/debug/DebugFragment.kt | 7 +- .../display/XServerDisplayActivity.java | 2 +- app/src/main/runtime/system/LogManager.kt | 67 +++++++++++++++++-- 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index 3c32a53b5..924713bb5 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 @@ -309,7 +310,7 @@ class DebugFragment : Fragment() { } startActivity(Intent.createChooser(shareIntent, getString(R.string.settings_debug_share_logs))) - Handler(Looper.getMainLooper()).postDelayed({ cleanupSharedLogs() }, 3 * 60 * 1000L) + Handler(Looper.getMainLooper()).postDelayed({ cleanupSharedLogs(ctx) }, 3 * 60 * 1000L) } catch (e: Exception) { WinToast.show(ctx, getString(R.string.settings_debug_capture_failed, e.message ?: "")) } @@ -478,6 +479,7 @@ class DebugFragment : Fragment() { file.delete() val set = HashSet(downloadedLogKeys()) if (set.remove(key)) preferences.edit { putStringSet(KEY_DOWNLOADED_LOGS, set) } + LogManager.resetLoggedExitKeys(preferences) refresh() } @@ -488,11 +490,12 @@ class DebugFragment : Fragment() { var lastSharedLogFile: File? = null /** Call when starting a new game or after 3min timeout to clean up shared logs. */ - fun cleanupSharedLogs() { + fun cleanupSharedLogs(ctx: Context) { lastSharedLogFile?.let { file -> if (file.exists()) file.delete() lastSharedLogFile = null } + LogManager.resetLoggedExitKeys(ctx) } } } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 10ccff79d..6eea503f9 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -938,7 +938,7 @@ public void onCreate(Bundle savedInstanceState) { "requestDismissKeyguard failed: " + t.getMessage()); } } - DebugFragment.Companion.cleanupSharedLogs(); + DebugFragment.Companion.cleanupSharedLogs(this); com.winlator.cmod.runtime.system.LogManager.prepareForNewSession(this); preferences = PreferenceManager.getDefaultSharedPreferences(this); diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 9ffcc1dee..47b22289f 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -8,6 +8,7 @@ import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.util.Log +import androidx.annotation.RequiresApi import androidx.core.app.ActivityCompat import androidx.preference.PreferenceManager import com.winlator.cmod.app.config.SettingsConfig @@ -18,6 +19,7 @@ import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import androidx.core.content.edit object LogManager { private const val TAG = "LogManager" @@ -68,6 +70,7 @@ object LogManager { 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" + private const val PREF_LOGGED_EXIT_KEYS = "logged_exit_keys" // ── Cached state ────────────────────────────────────────────────── // @@ -243,6 +246,7 @@ object LogManager { val logsDir = getLogsDir(context) logsDir.listFiles()?.filter { it.name.endsWith(".old.log") }?.forEach { it.delete() } logsDir.listFiles()?.filter { it.name.endsWith(".log") }?.forEach { it.delete() } + resetLoggedExitKeys(context) startAppLogging(context) } @@ -353,6 +357,7 @@ object LogManager { fun clearLogs(context: Context) { getLogsDir(context).listFiles()?.forEach { it.delete() } + resetLoggedExitKeys(context) } @JvmStatic @@ -429,7 +434,25 @@ object LogManager { /** Deletes all shareable log files; returns the count removed. */ @JvmStatic - fun deleteShareableLogs(context: Context): Int = getShareableLogFiles(context).count { it.delete() } + fun deleteShareableLogs(context: Context): Int { + var count = getShareableLogFiles(context).count { it.delete() } + resetLoggedExitKeys(context) + return count + } + + /** Deletes the logged exit keys so the log can be re-written. */ + @JvmStatic + fun resetLoggedExitKeys(context: Context) { + // Reset dedup keys so the next logLastExitReasons() call re-writes everything. + PreferenceManager.getDefaultSharedPreferences(context) + .edit { remove(PREF_LOGGED_EXIT_KEYS) } + } + + /** Deletes the logged exit keys so the log can be re-written. */ + @JvmStatic + fun resetLoggedExitKeys(preferences: SharedPreferences) { + preferences.edit { remove(PREF_LOGGED_EXIT_KEYS) } + } // ── 1. Custom breadcrumbs, callable from anywhere ─────────────── // @@ -639,12 +662,21 @@ object LogManager { appendLine(ctx, EXIT_REASONS_FILE, "I/$TAG", "No historical exit info available") return } + + val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) + val loggedKeys = getLoggedExitKeys(prefs).toMutableSet() + var wroteSomething = false + for ((index, info) in infos.withIndex()) { - if (cachedExitReasonLogEnabled) { + val key = exitKey(info) + val exitKey = "exit_$key" + val crashKey = "crash_$key" + + if (cachedExitReasonLogEnabled && exitKey !in loggedKeys) { // 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) ----" + "\n---- Exit reason #${index} (0=new/last, ${maxExitReasons}=oldest) ----" ) appendLine( @@ -652,8 +684,10 @@ object LogManager { "pid=${info.pid} reason=${info.reason}-[${getExitReasonName(info.reason)}] status=${info.status} " + "importance=${info.importance} desc=${info.description} timestamp=${Date(info.timestamp)}", ) + loggedKeys.add(exitKey) + wroteSomething = true } - if (cachedCrashLogEnabled) { + if (cachedCrashLogEnabled && crashKey !in loggedKeys) { val isErrorReport = when (info.reason) { ApplicationExitInfo.REASON_CRASH, ApplicationExitInfo.REASON_CRASH_NATIVE, @@ -675,7 +709,7 @@ object LogManager { val summary = summarizeTrace(rawTrace, info.reason) appendLine( ctx, CRASH_FILE, "I/$TAG", - "=== Historical $type Detected ===\n" + + "\n=== Historical $type Detected ===\n" + "PID: ${info.pid} | Timestamp: ${Date(info.timestamp)}\n" + "Description: ${info.description}\n" + "Trace Summary:\n$summary\n" + @@ -689,8 +723,15 @@ object LogManager { Timber.tag(TAG).e("Failed to read historical trace: ${e.message}") } } + // Mark as crash-processed to avoid re-scanning non-crash reasons + loggedKeys.add(crashKey) + wroteSomething = true } } + + if (wroteSomething) { + saveLoggedExitKeys(prefs, loggedKeys) + } } catch (e: Exception) { Timber.tag(TAG).e("Failed to read exit reasons: ${e.message}") } @@ -735,6 +776,22 @@ object LogManager { } } + // A unique, stable key for one ApplicationExitInfo record. + @RequiresApi(Build.VERSION_CODES.R) + private fun exitKey(info: ApplicationExitInfo): String = "${info.pid}_${info.timestamp}" + + private fun getLoggedExitKeys(prefs: SharedPreferences): Set = + prefs.getString(PREF_LOGGED_EXIT_KEYS, null) + ?.split(",")?.filter { it.isNotEmpty() }?.toSet() + ?: emptySet() + + private fun saveLoggedExitKeys(prefs: SharedPreferences, keys: Set) { + // Sort descending based on the timestamp (the suffix after the last underscore) + // We keep up to 30 entries to cover both 'exit_' and 'crash_' for the 5-item historical window. + val trimmed = keys.sortedByDescending { it.substringAfterLast("_").toLongOrNull() ?: 0L }.take(30) + prefs.edit { putString(PREF_LOGGED_EXIT_KEYS, trimmed.joinToString(",")) } + } + private fun summarizeTrace(rawTrace: String, reason: Int): String { return when (reason) { ApplicationExitInfo.REASON_ANR -> summarizeAnrTrace(rawTrace) From 4d050d8f6f4d0a62497d1f090d802120b3511807 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sun, 12 Jul 2026 11:07:07 +0200 Subject: [PATCH 22/35] Updated some log lines in XServerDisplayActivity --- app/src/main/runtime/display/XServerDisplayActivity.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 0da66a4ed..cc242c5b7 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -888,7 +888,7 @@ private void handleDebugInjectTap(Intent intent) { private void switchLaunchTargetAfterCleanup(Intent intent) { if (!switchLaunchInProgress.compareAndSet(false, true)) { - LogManager.log("XServerDisplayActivity", "Switch launch already in progress; ignoring duplicate target intent", this); + LogManager.log(TAG, "Switch launch already in progress; ignoring duplicate target intent", this); return; } @@ -905,7 +905,7 @@ private void switchLaunchTargetAfterCleanup(Intent intent) { performForcedSessionCleanup("switch launch target"); runOnUiThread(() -> { if (isFinishing() || isDestroyed()) { - LogManager.logW("XServerDisplayActivity", "Switch cleanup finished after activity was destroyed", null, this); + LogManager.logW(TAG, "Switch cleanup finished after activity was destroyed", null, this); return; } setIntent(relaunchIntent); @@ -937,7 +937,7 @@ public void onCreate(Bundle savedInstanceState) { km.requestDismissKeyguard(this, null); } } catch (Throwable t) { - Log.w("XServerDisplayActivity", + Log.w(TAG, "requestDismissKeyguard failed: " + t.getMessage()); } } From c5779958e826d4e09134d0afdf0d6553e029b694 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sun, 12 Jul 2026 11:17:00 +0200 Subject: [PATCH 23/35] Commented out a log line in XConnectorEpoll to avoid unnecessary noise. This log makes it possible to identify which calls are responsible for triggering a silent crash that was fixed in this PR. --- app/src/main/runtime/display/connector/XConnectorEpoll.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/runtime/display/connector/XConnectorEpoll.java b/app/src/main/runtime/display/connector/XConnectorEpoll.java index 5975f63fa..ba20751c3 100644 --- a/app/src/main/runtime/display/connector/XConnectorEpoll.java +++ b/app/src/main/runtime/display/connector/XConnectorEpoll.java @@ -147,10 +147,9 @@ public void killConnection(Client client, String 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); + // Log immediately on entry: fd, reason, whether multithreadedClients - Commented out to avoid noise + // 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; From 8c005de6aee4df2398c2589e1b6f26be32df661f Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sun, 12 Jul 2026 15:37:11 +0200 Subject: [PATCH 24/35] Fixed some lines and logs that shouldn't appear in some conditions. --- app/src/main/runtime/system/LogManager.kt | 1 - app/src/main/runtime/system/SessionKeepAliveService.java | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 47b22289f..25a6a45a8 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -579,7 +579,6 @@ object LogManager { closeProcessStdin(eventWatchProcess) } catch (e: Exception) { -// Timber.tag(TAG).e("Failed to start event watch: ${e.message}") logE(TAG, null, context) { "Failed to start event watch: ${e.message}" } } } diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 4a9c0a26d..ffa248472 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -143,7 +143,7 @@ public static void onPauseSession(Context ctx) { LogManager.log(TAG, "onPauseSession", ctx); if (instance != null) { instance.acquireWakeLock(); - instance.runOomSweep(); +// instance.runOomSweep(); instance.startHeartbeat(); } updateForegroundState(ctx); @@ -559,8 +559,8 @@ private void stopHeartbeat() { if (t != null) { t.interrupt(); heartbeatThread = null; + LogManager.log(TAG, "Heartbeat stopped", this); } - LogManager.log(TAG, "Heartbeat stopped", this); } private void runOomSweep() { @@ -572,7 +572,6 @@ 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); } } From 70425188ef9e371534b24b0981c1eb463d2974f2 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sun, 12 Jul 2026 15:44:51 +0200 Subject: [PATCH 25/35] Fixed issues introduced in the commit that enables background protection by default. - It should now only be enabled by default, and correctly, on Android 14+ versions. - WakeLock is enabled by default on Android 16+ versions. - Fixed a bug where the notifications button appeared directly as granted in SetupWizardActivity. - Moved the `Output to External Display` setting out of the background section in OtherSettingsScreen. --- .../settings/other/OtherSettingsFragment.kt | 3 ++- .../settings/other/OtherSettingsScreen.kt | 4 +-- .../main/feature/setup/SetupWizardActivity.kt | 25 ++++++++++++++----- .../display/XServerDisplayActivity.java | 4 +-- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/app/src/main/feature/settings/other/OtherSettingsFragment.kt b/app/src/main/feature/settings/other/OtherSettingsFragment.kt index 07e95d844..9484c90a2 100644 --- a/app/src/main/feature/settings/other/OtherSettingsFragment.kt +++ b/app/src/main/feature/settings/other/OtherSettingsFragment.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -252,7 +253,7 @@ 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", true), + 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), diff --git a/app/src/main/feature/settings/other/OtherSettingsScreen.kt b/app/src/main/feature/settings/other/OtherSettingsScreen.kt index d2f2c2968..60841abb9 100644 --- a/app/src/main/feature/settings/other/OtherSettingsScreen.kt +++ b/app/src/main/feature/settings/other/OtherSettingsScreen.kt @@ -335,6 +335,8 @@ fun OtherSettingsScreen( onModeChanged = onBackgroundPauseModeChanged, ) + SectionLabel(stringResource(R.string.settings_other_section_integration), modifier = Modifier.padding(top = 8.dp)) + SettingsToggleCard( title = stringResource(R.string.session_drawer_output_to_display), subtitle = stringResource(R.string.settings_external_display_output_summary), @@ -343,8 +345,6 @@ fun OtherSettingsScreen( onCheckedChange = onExternalDisplayOutputChanged, ) - SectionLabel(stringResource(R.string.settings_other_section_integration), modifier = Modifier.padding(top = 8.dp)) - SettingsToggleCard( title = stringResource(R.string.settings_general_enable_file_provider), subtitle = stringResource(R.string.settings_general_file_provider_summary), diff --git a/app/src/main/feature/setup/SetupWizardActivity.kt b/app/src/main/feature/setup/SetupWizardActivity.kt index 70eb4ffb3..c34832cdb 100644 --- a/app/src/main/feature/setup/SetupWizardActivity.kt +++ b/app/src/main/feature/setup/SetupWizardActivity.kt @@ -140,6 +140,7 @@ import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin import androidx.core.content.edit +import timber.log.Timber private data class Particle( val x: Float, @@ -534,6 +535,8 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { fallbackUrl = "https://github.com/nicholasx417/WinNative-Components/releases/download/Proton/Proton-10-arm64ec-coffincolors.wcp", ) + private val TAG = "SetupWizardActivity"; + private val storageGranted = mutableStateOf(false) private val notifGranted = mutableStateOf(false) private val notifDenied = mutableStateOf(false) @@ -889,10 +892,20 @@ 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) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // Enable background protection by default on Android 14+. + androidx.preference.PreferenceManager.getDefaultSharedPreferences(this) + .edit { putBoolean("enable_background_session", true) } + Timber.d("Android 14+ detected") + } + backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", false) + if (Build.VERSION.SDK_INT >= 36) { + Timber.d("Android 16+ detected") + // If wakeLock preference isn't saved, enable it by default on Android 16+. + if (!androidx.preference.PreferenceManager.getDefaultSharedPreferences(this).contains("enable_background_wakelock")) { + androidx.preference.PreferenceManager.getDefaultSharedPreferences(this).edit { + putBoolean("enable_background_wakelock", true) + } + Timber.d("Android 16+ wakeLock preference enabled") } } refreshWizardState() @@ -936,7 +949,7 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { if (notificationsEnabled) { notifDenied.value = false } - backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", true) + backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", false) refreshWizardState() refreshRecommendedPackageCache() } @@ -1013,7 +1026,7 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { private fun requestNotifications() { if (hasNotificationPermissionSilently()) { backgroundSessionEnabled.value = true - prefs(this).edit().putBoolean("enable_background_session", true).apply() + prefs(this).edit { putBoolean("enable_background_session", true) } return } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index cc242c5b7..c067bfc78 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -1547,7 +1547,7 @@ public void handleOnBackPressed() { showLaunchPreloader(getString(R.string.preloader_initializing)); // Dependency-install sessions must not become background/reattachable sessions. - if (!isDependencyInstall && preferences.getBoolean("enable_background_session", true)) { + if (!isDependencyInstall && preferences.getBoolean("enable_background_session", false)) { SessionKeepAliveService.startSession(this); } @@ -3860,7 +3860,7 @@ protected void onDestroy() { } if (!sessionCleanupStarted.get()) { - if (exitRequested.get() || !preferences.getBoolean("enable_background_session", true)) { + if (exitRequested.get() || !preferences.getBoolean("enable_background_session", false)) { performForcedSessionCleanup("onDestroy"); } } From 45092402dcc72e709f1dc85eb69b2e9c003c5112 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sun, 12 Jul 2026 20:52:51 +0200 Subject: [PATCH 26/35] Replaced hardcoded foreground notification content strings with translatable strings. - Removed unused variable in XServerDisplayActivity (was used for a previous log test). --- app/src/main/res/values/strings.xml | 8 ++++++++ .../runtime/display/XServerDisplayActivity.java | 1 - app/src/main/runtime/system/LogManager.kt | 7 +++---- .../runtime/system/SessionKeepAliveService.java | 17 +++++++---------- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4270b3d83..f9eb683cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1597,4 +1597,12 @@ Installed path: Delete failed Rename failed Create folder failed + + Container session is paused + There is a container session running + Downloading and installing components + Steam chat running in background + service is active + services are active + and diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index c067bfc78..71eefb107 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -491,7 +491,6 @@ private boolean isAnyControllerConnected() { private boolean enableLogsMenu; private boolean autoPauseContainer; private static final String TAG = "XServerDisplayActivity"; - private static final AtomicLong breadcrumbCounter = new AtomicLong(0); private GuestProgramLauncherComponent guestProgramLauncherComponent; private EnvVars overrideEnvVars; diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 25a6a45a8..c2384216a 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -552,10 +552,9 @@ object LogManager { if (!cachedEventWatchEnabled) 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" } - } + val hasReadLogs = (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_LOGS) + == PackageManager.PERMISSION_GRANTED) + if (!hasReadLogs) logW(TAG, null, context) { "READ_LOGS permission not granted, pause watch may not capture system logs" } stopEventWatch() try { diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index ffa248472..88030818f 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -19,6 +19,7 @@ import androidx.annotation.Nullable; 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; @@ -580,17 +581,17 @@ private void runOomSweepInternal() { 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"; + return isContainerPaused ? instance.getString(R.string.fg_keep_alive_notification_content_container_paused) : instance.getString(R.string.fg_keep_alive_notification_content_container_running); } // 2. MEDIUM PRIORITY: Downloads synchronized (activeDownloads) { - if (!activeDownloads.isEmpty()) return "Downloading and installing components in the background"; + if (!activeDownloads.isEmpty()) return instance.getString(R.string.fg_keep_alive_notification_content_downloading_installing); } // 3. MEDIUM PRIORITY: Steam friends (if enabled) if (PrefManager.INSTANCE.getChatStayRunningOnExit() && isAppVisible()) - return "Steam chat running in background"; + return instance.getString(R.string.fg_keep_alive_notification_content_steam_chat_running); // 4. LOW PRIORITY: Active store services if (!activeComponents.isEmpty()) { @@ -616,7 +617,7 @@ private static String getNotificationContent() { joinedNames = names.get(0); break; case 2: - joinedNames = names.get(0) + " and " + names.get(1); + joinedNames = names.get(0) + ' ' + instance.getString(R.string.general_and) + ' ' + names.get(1); break; default: // Future-proof: "A, B, and C" @@ -626,17 +627,13 @@ private static String getNotificationContent() { if (i < size - 2) { sb.append(", "); } else if (i == size - 2) { - sb.append(", and "); + sb.append(", " + instance.getString(R.string.general_and) + ' '); } } joinedNames = sb.toString(); } - /*return names.size() == 1 - ? names.get(0) + " service is active" - : String.join(" and ", names) + " services are active";*/ - - String suffix = (size == 1) ? " service is active" : " services are active"; + String suffix = (size == 1) ? instance.getString(R.string.fg_keep_alive_notification_content_store_service_active) : instance.getString(R.string.fg_keep_alive_notification_content_store_services_active); return joinedNames + suffix; } return "WinNative is running in the background"; From 14aa9e47c5950a8e6ec0f3124005b34d5612b9fd Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Mon, 13 Jul 2026 10:56:29 +0200 Subject: [PATCH 27/35] Renamed log file generated with filtered tags. --- app/src/main/runtime/system/LogManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index c2384216a..24a895f40 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -23,7 +23,7 @@ import androidx.core.content.edit object LogManager { private const val TAG = "LogManager" - private const val APP_LOG_FILE = "app_debug.log" + private const val APP_LOG_FILE = "app_filtered-logs.log" private const val EXIT_REASONS_FILE = "exit_reasons.log" private const val CRASH_FILE = "crash.log" From 92a14ff10ff7ddd725807f42c672b8bfaf39e475 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Mon, 13 Jul 2026 19:22:03 +0200 Subject: [PATCH 28/35] UI: Improved the window appearance of the Custom tag filter. --- .../feature/settings/debug/DebugScreen.kt | 141 +++++++++++------- 1 file changed, 88 insertions(+), 53 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index 539efd400..ddecccdb1 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -138,6 +138,7 @@ 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.material3.HorizontalDivider import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction @@ -1008,8 +1009,10 @@ private fun MultiSelectDialog( initiallySelected: List, onDismiss: () -> Unit, onConfirm: (List) -> Unit, + headerContent: (@Composable () -> Unit)? = null, extraContent: (@Composable (selected: Set, onToggle: (String) -> Unit) -> Unit)? = null, systemTags: Set = emptySet(), + maxWidth: Dp = 460.dp, ) { val selected = remember(initiallySelected) { @@ -1094,7 +1097,7 @@ private fun MultiSelectDialog( Box( modifier = Modifier - .widthIn(max = 460.dp) + .widthIn(max = maxWidth) .fillMaxWidth() .heightIn(max = availableHeight) .clip(RoundedCornerShape(18.dp)) @@ -1110,6 +1113,9 @@ private fun MultiSelectDialog( fontWeight = FontWeight.SemiBold, ) Spacer(Modifier.height(4.dp)) + + headerContent?.invoke() + // Optional hint area can be provided by caller via extraContent or you can keep a default hint Spacer(Modifier.height(12.dp)) @@ -1212,79 +1218,108 @@ private fun LogTagFilterDialog( initiallySelected = initiallySelected, onDismiss = onDismiss, onConfirm = { selected -> onConfirm(mode, selected) }, - extraContent = { selectedSet, onToggle -> - Column { - // Mode switch row + maxWidth = 680.dp, + headerContent = { + Column(modifier = Modifier.padding(top = 16.dp)) { + // Top Row: Mode Switch and Add Field side-by-side Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(20.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 + // Mode Section + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_debug_tag_filter_mode).uppercase(), + color = TextSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp + ) + Spacer(Modifier.height(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 = "" - } + // Add Custom Tag Section + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_debug_add_custom_tag_hint).uppercase(), + color = TextSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp + ) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = newTagText, + onValueChange = { newTagText = it }, + placeholder = { Text(stringResource(R.string.settings_debug_add_custom_tag_hint), fontSize = 12.sp) }, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 13.sp), + modifier = Modifier.weight(1f).height(38.dp), + shape = RoundedCornerShape(8.dp), + colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors( + focusedBorderColor = Accent, + unfocusedBorderColor = CardBorder + ) + ) + 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 + // Custom Tags row if (customTags.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(6.dp)) + HorizontalDivider(color = Color(0xFF2A2A3A), thickness = 0.5.dp) + Spacer(Modifier.height(6.dp)) Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { + Text("CUSTOM:", color = TextSecondary, fontSize = 10.sp, fontWeight = FontWeight.Bold) customTags.forEach { tag -> RemovableTagChip(tag = tag, onRemove = { onRemoveCustomTag(tag) }) } } + Spacer(Modifier.height(3.dp)) + HorizontalDivider(color = Color(0xFF2A2A3A), thickness = 0.5.dp) } - - Spacer(Modifier.height(8.dp)) } - } + }, ) } From 4052026f10866edd09c1e99f75223c4b252e4cbe Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Mon, 13 Jul 2026 19:35:02 +0200 Subject: [PATCH 29/35] Fix: Avoid deleting the ExitReasons and Crash keys when deleting an unrelated log file. --- app/src/main/feature/settings/debug/DebugFragment.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index 924713bb5..5fa8db55f 100644 --- a/app/src/main/feature/settings/debug/DebugFragment.kt +++ b/app/src/main/feature/settings/debug/DebugFragment.kt @@ -476,10 +476,12 @@ class DebugFragment : Fragment() { private fun deleteLogFile(entry: LogFileEntry) { val file = File(entry.absolutePath) val key = logDownloadKey(file) + if (file.name.startsWith("crash") || file.name.startsWith("exit_reasons")) { + LogManager.resetLoggedExitKeys(preferences) + } file.delete() val set = HashSet(downloadedLogKeys()) if (set.remove(key)) preferences.edit { putStringSet(KEY_DOWNLOADED_LOGS, set) } - LogManager.resetLoggedExitKeys(preferences) refresh() } From 829ef5ab1d9b36d093f76a4f7bb690ada138dcd2 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Mon, 13 Jul 2026 21:45:17 +0200 Subject: [PATCH 30/35] Added post-mortem logic to LogManager and refactored duplicate checking in other logs. - The app now generates a post-mortem log on startup if the previous exit was caused by a crash and the Crash Log toggle is disabled. - Optimized the logLastExitReasons method to handle the keys used to avoid duplicates. - It now checks whether the log files exist directly instead of deleting the keys in each app function to remove those files. - Removed the changes in XServerDisplayActivity and DebugFragment that were previously needed to delete the keys used by logLastExitReasons. --- .../feature/settings/debug/DebugFragment.kt | 8 +- .../display/XServerDisplayActivity.java | 2 +- app/src/main/runtime/system/LogManager.kt | 372 +++++++++--------- 3 files changed, 191 insertions(+), 191 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index 5fa8db55f..0e8e3f1e9 100644 --- a/app/src/main/feature/settings/debug/DebugFragment.kt +++ b/app/src/main/feature/settings/debug/DebugFragment.kt @@ -310,7 +310,7 @@ class DebugFragment : Fragment() { } startActivity(Intent.createChooser(shareIntent, getString(R.string.settings_debug_share_logs))) - Handler(Looper.getMainLooper()).postDelayed({ cleanupSharedLogs(ctx) }, 3 * 60 * 1000L) + Handler(Looper.getMainLooper()).postDelayed({ cleanupSharedLogs() }, 3 * 60 * 1000L) } catch (e: Exception) { WinToast.show(ctx, getString(R.string.settings_debug_capture_failed, e.message ?: "")) } @@ -476,9 +476,6 @@ class DebugFragment : Fragment() { private fun deleteLogFile(entry: LogFileEntry) { val file = File(entry.absolutePath) val key = logDownloadKey(file) - if (file.name.startsWith("crash") || file.name.startsWith("exit_reasons")) { - LogManager.resetLoggedExitKeys(preferences) - } file.delete() val set = HashSet(downloadedLogKeys()) if (set.remove(key)) preferences.edit { putStringSet(KEY_DOWNLOADED_LOGS, set) } @@ -492,12 +489,11 @@ class DebugFragment : Fragment() { var lastSharedLogFile: File? = null /** Call when starting a new game or after 3min timeout to clean up shared logs. */ - fun cleanupSharedLogs(ctx: Context) { + fun cleanupSharedLogs() { lastSharedLogFile?.let { file -> if (file.exists()) file.delete() lastSharedLogFile = null } - LogManager.resetLoggedExitKeys(ctx) } } } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 3c24f4c44..1475ff1bf 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -940,7 +940,7 @@ public void onCreate(Bundle savedInstanceState) { "requestDismissKeyguard failed: " + t.getMessage()); } } - DebugFragment.Companion.cleanupSharedLogs(this); + DebugFragment.Companion.cleanupSharedLogs(); com.winlator.cmod.runtime.system.LogManager.prepareForNewSession(this); preferences = PreferenceManager.getDefaultSharedPreferences(this); diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 24a895f40..cfab00351 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -26,6 +26,7 @@ object LogManager { private const val APP_LOG_FILE = "app_filtered-logs.log" private const val EXIT_REASONS_FILE = "exit_reasons.log" private const val CRASH_FILE = "crash.log" + private const val POST_MORTEM_FILE = "post_mortem.log" private var logcatProcess: Process? = null private var appLogProcess: Process? = null @@ -71,6 +72,7 @@ object LogManager { private const val PREF_SELECTED_TAGS = "app_debug_tags" private const val PREF_CUSTOM_TAGS = "app_debug_custom_tags" private const val PREF_LOGGED_EXIT_KEYS = "logged_exit_keys" + private const val PREF_POST_MORTEM_KEYS = "post_mortem_keys" // ── Cached state ────────────────────────────────────────────────── // @@ -144,8 +146,8 @@ object LogManager { crashHandlerInitialized = true } - // Capture previous exit reasons - logLastExitReasons(app) + logLastExitReasons(app) // Capture previous exit reasons if toggle is enabled. + runPostMortemIfNeeded(app) // Capture unexpected crashes at start if crash toggle is disabled. } private fun refreshCaches(context: Context) { @@ -246,7 +248,6 @@ object LogManager { val logsDir = getLogsDir(context) logsDir.listFiles()?.filter { it.name.endsWith(".old.log") }?.forEach { it.delete() } logsDir.listFiles()?.filter { it.name.endsWith(".log") }?.forEach { it.delete() } - resetLoggedExitKeys(context) startAppLogging(context) } @@ -265,7 +266,7 @@ object LogManager { if (cleaned.isEmpty()) return val prefs = PreferenceManager.getDefaultSharedPreferences(context) val updated = cachedCustomTags + cleaned - prefs.edit().putString(PREF_CUSTOM_TAGS, updated.joinToString(",")).apply() + prefs.edit { putString(PREF_CUSTOM_TAGS, updated.joinToString(",")) } } @JvmStatic @@ -273,16 +274,17 @@ object LogManager { 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() + prefs.edit { + putString(PREF_CUSTOM_TAGS, updatedCustomTags.joinToString(",")) + .putString(PREF_SELECTED_TAGS, updatedSelectedTags.joinToString(",")) + } } @JvmStatic fun setSelectedTags(context: Context, tags: Set) { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putString(PREF_SELECTED_TAGS, tags.joinToString(",")).apply() + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(PREF_SELECTED_TAGS, tags.joinToString(",")) + } } @JvmStatic @@ -290,8 +292,9 @@ object LogManager { @JvmStatic fun setTagFilterMode(context: Context, mode: TagFilterMode) { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putString(PREF_TAG_FILTER_MODE, mode.name).apply() + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(PREF_TAG_FILTER_MODE, mode.name) + } } @JvmStatic @@ -357,7 +360,6 @@ object LogManager { fun clearLogs(context: Context) { getLogsDir(context).listFiles()?.forEach { it.delete() } - resetLoggedExitKeys(context) } @JvmStatic @@ -435,23 +437,7 @@ object LogManager { /** Deletes all shareable log files; returns the count removed. */ @JvmStatic fun deleteShareableLogs(context: Context): Int { - var count = getShareableLogFiles(context).count { it.delete() } - resetLoggedExitKeys(context) - return count - } - - /** Deletes the logged exit keys so the log can be re-written. */ - @JvmStatic - fun resetLoggedExitKeys(context: Context) { - // Reset dedup keys so the next logLastExitReasons() call re-writes everything. - PreferenceManager.getDefaultSharedPreferences(context) - .edit { remove(PREF_LOGGED_EXIT_KEYS) } - } - - /** Deletes the logged exit keys so the log can be re-written. */ - @JvmStatic - fun resetLoggedExitKeys(preferences: SharedPreferences) { - preferences.edit { remove(PREF_LOGGED_EXIT_KEYS) } + return getShareableLogFiles(context).count { it.delete() } } // ── 1. Custom breadcrumbs, callable from anywhere ─────────────── @@ -656,15 +642,28 @@ object LogManager { try { val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager val infos: List = am.getHistoricalProcessExitReasons(ctx.packageName, 0, maxExitReasons) + + val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) + val logsDir = getLogsDir(ctx) + val loggedKeys = getPersistedKeys(prefs, PREF_LOGGED_EXIT_KEYS).toMutableSet() + + // Auto-reset keys when a log file no longer exists — covers manual deletion, + // clearLogs() calls, and fresh installs without needing external management. + val originalSize = loggedKeys.size + if (!File(logsDir, EXIT_REASONS_FILE).exists()) loggedKeys.removeAll { it.startsWith("exit_") } + if (!File(logsDir, CRASH_FILE).exists()) loggedKeys.removeAll { it.startsWith("crash_") } + + var wroteSomething = (loggedKeys.size != originalSize) + if (infos.isEmpty()) { - appendLine(ctx, EXIT_REASONS_FILE, "I/$TAG", "No historical exit info available") + // Only write the "no info" placeholder if the file is missing/just cleared. + if (cachedExitReasonLogEnabled && !File(logsDir, EXIT_REASONS_FILE).exists()) { + appendLine(ctx, EXIT_REASONS_FILE, "I/$TAG", "No historical exit info available") + } + if (wroteSomething) persistKeys(prefs, PREF_LOGGED_EXIT_KEYS, loggedKeys) return } - val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) - val loggedKeys = getLoggedExitKeys(prefs).toMutableSet() - var wroteSomething = false - for ((index, info) in infos.withIndex()) { val key = exitKey(info) val exitKey = "exit_$key" @@ -704,7 +703,7 @@ object LogManager { try { info.traceInputStream?.use { input -> val rawTrace = input.bufferedReader().readText() - val summary = summarizeTrace(rawTrace, info.reason) + val summary = extractTraceExcerpt(rawTrace, info.reason, maxFrames = 20) appendLine( ctx, CRASH_FILE, "I/$TAG", "\n=== Historical $type Detected ===\n" + @@ -728,7 +727,7 @@ object LogManager { } if (wroteSomething) { - saveLoggedExitKeys(prefs, loggedKeys) + persistKeys(prefs, PREF_LOGGED_EXIT_KEYS, loggedKeys) } } catch (e: Exception) { Timber.tag(TAG).e("Failed to read exit reasons: ${e.message}") @@ -774,175 +773,180 @@ object LogManager { } } - // A unique, stable key for one ApplicationExitInfo record. - @RequiresApi(Build.VERSION_CODES.R) - private fun exitKey(info: ApplicationExitInfo): String = "${info.pid}_${info.timestamp}" + /** + * Runs once per app start. Checks whether the previous run ended abnormally + * and writes a concise diagnostic to post_mortem.log. + */ + @JvmStatic + fun runPostMortemIfNeeded(context: Context? = null) { + if (cachedCrashLogEnabled) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + val ctx = resolveContext(context) ?: return + val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) - private fun getLoggedExitKeys(prefs: SharedPreferences): Set = - prefs.getString(PREF_LOGGED_EXIT_KEYS, null) - ?.split(",")?.filter { it.isNotEmpty() }?.toSet() - ?: emptySet() + // Auto-reset keys when the file no longer exists — covers log deletion, + // fresh installs, and manual file removal without needing clearLogs(). + val postMortemFile = File(getLogsDir(ctx), POST_MORTEM_FILE) + if (!postMortemFile.exists()) { + prefs.edit { remove(PREF_POST_MORTEM_KEYS) } + } - private fun saveLoggedExitKeys(prefs: SharedPreferences, keys: Set) { - // Sort descending based on the timestamp (the suffix after the last underscore) - // We keep up to 30 entries to cover both 'exit_' and 'crash_' for the 5-item historical window. - val trimmed = keys.sortedByDescending { it.substringAfterLast("_").toLongOrNull() ?: 0L }.take(30) - prefs.edit { putString(PREF_LOGGED_EXIT_KEYS, trimmed.joinToString(",")) } - } + try { + val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val infos = am.getHistoricalProcessExitReasons(ctx.packageName, 0, 5) + if (infos.isEmpty()) return - 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 + val loggedKeys = getPersistedKeys(prefs, PREF_POST_MORTEM_KEYS).toMutableSet() + var wrote = false + + for (info in infos) { + if (info.reason !in POST_MORTEM_REASONS) continue + val key = "pm_${exitKey(info)}" + if (key in loggedKeys) continue + + appendLine(ctx, POST_MORTEM_FILE, "I/$TAG", buildPostMortemReport(info, ctx)) + loggedKeys.add(key) + wrote = true + } + if (wrote) persistKeys(prefs, PREF_POST_MORTEM_KEYS, loggedKeys) + } catch (e: Exception) { + Timber.tag(TAG).e("Post-mortem check failed: ${e.message}") } } - /** - * 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) + @RequiresApi(Build.VERSION_CODES.R) + private fun buildPostMortemReport(info: ApplicationExitInfo, context: Context): String { + return buildString { + appendLine("=== POST-MORTEM [${getExitReasonName(info.reason)}] ===") + appendLine("Time : ${Date(info.timestamp)}") + appendLine("PID : ${info.pid}") + appendLine("Reason : ${info.reason} [${getExitReasonName(info.reason)}]") + appendLine("Status : ${info.status}") + appendLine("Desc : ${info.description ?: "none"}") + try { + val pkg = context.packageManager.getPackageInfo(context.packageName, 0) + appendLine("Build : ${pkg.versionName} (${pkg.longVersionCode})") + } catch (_: Exception) {} + appendLine("Android : ${Build.VERSION.SDK_INT} (${Build.VERSION.RELEASE})") + appendLine("Device : ${Build.MANUFACTURER} ${Build.MODEL}") + try { + info.traceInputStream?.use { stream -> + val excerpt = extractTraceExcerpt( + stream.bufferedReader().readText(), info.reason, maxFrames = 5 + ) + if (excerpt.isNotBlank()) { + appendLine("--- Excerpt ---") + appendLine(excerpt.trim()) + appendLine("--- End Excerpt ---") + } } - // Skip: RSS stats, VmSwap, CriticalEventLog metadata, libdebuggerd lines + } catch (e: Exception) { + appendLine("Excerpt : unavailable (${e.message})") } + append("=== END POST-MORTEM ===") } + } + + // A unique, stable key for one ApplicationExitInfo record. + @RequiresApi(Build.VERSION_CODES.R) + private fun exitKey(info: ApplicationExitInfo): String = "${info.pid}_${info.timestamp}" + + private fun getPersistedKeys(prefs: SharedPreferences, prefKey: String): Set = + prefs.getString(prefKey, null) + ?.split(",")?.filter { it.isNotEmpty() }?.toSet() + ?: emptySet() - return out.toString().trimEnd().ifEmpty { - // Fallback: if the format changed and nothing matched, return first 30 lines - lines.take(30).joinToString("\n") + private fun persistKeys(prefs: SharedPreferences, prefKey: String, keys: Set) { + prefs.edit { + putString(prefKey, keys.sortedDescending().take(20).joinToString(",")) } } - /** - * 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 { + // ── Unified trace extraction ────────────────────────────────────────── + // + // Used for both the crash log (maxFrames = 20) and the post-mortem + // (maxFrames = 5). Higher maxFrames → more context; lower → more concise. + + // For avoid useless lines in the crash log. + private val FRAMEWORK_FRAME_PREFIXES = setOf( + "at java.", "at kotlin.", "at android.", "at androidx.", + "at com.android.", "at dalvik.", "at sun.", "at libcore.", + ) + + private fun isFrameworkFrame(line: String): Boolean = + FRAMEWORK_FRAME_PREFIXES.any { line.trimStart().startsWith(it) } + + private fun extractTraceExcerpt(raw: String, reason: Int, maxFrames: Int = 6): 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) { + val concise = maxFrames <= 6 + + return when (reason) { + ApplicationExitInfo.REASON_ANR -> { + val subject = lines.firstOrNull { it.startsWith("Subject:") } + // Waiting Channels shows which kernel call each thread is stuck in. + val channelHeader = lines.firstOrNull { it.contains("Waiting Channels:") } + val channelLines = lines + .dropWhile { !it.contains("Waiting Channels:") } + .drop(1) + .filter { it.contains("sysTid=") } + .take(maxFrames) + (listOfNotNull(subject, channelHeader) + channelLines).joinToString("\n") + } + + ApplicationExitInfo.REASON_CRASH -> { + val out = StringBuilder() + var inException = false + var frameCount = 0 + for (line in lines) { + val t = line.trimStart() + when { + !inException && (t.contains("Exception") || t.contains("Error") + || t.startsWith("Exception in thread")) -> { + inException = true out.appendLine(line) } + inException && t.startsWith("at ") && frameCount < maxFrames -> { + // Concise: take every frame to stay within maxFrames. + // Verbose: skip pure framework frames to highlight app code. + if (concise || !isFrameworkFrame(line)) { + out.appendLine(line) + frameCount++ + } + } + inException && t.startsWith("Caused by:") -> { + out.appendLine(line) + frameCount = 0 + } + inException && t.isBlank() -> break } } - inStack && line.trimStart().startsWith("Caused by:") -> { - out.appendLine(line) - appFrameCount = 0 // reset per cause - } - inStack && line.isBlank() -> { - inStack = false - } + out.toString().trimEnd().ifEmpty { lines.take(maxFrames).joinToString("\n") } } - } - return out.toString().trimEnd().ifEmpty { lines.take(30).joinToString("\n") } + ApplicationExitInfo.REASON_CRASH_NATIVE -> { + val signal = lines.firstOrNull { it.startsWith("signal ") } + val abort = lines.firstOrNull { it.startsWith("Abort message:") } + val frames = lines.filter { it.trimStart().startsWith("#") }.take(maxFrames) + (listOfNotNull(signal, abort) + frames).joinToString("\n") + } + + // SIGNALED / LOW_MEMORY / EXCESSIVE_RESOURCE_USAGE: + // no trace content is available; the header fields are sufficient. + else -> "" + } } /** - * 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. + * Exit reasons that constitute an abnormal termination and trigger a + * post-mortem report. Add entries here to cover new cases to be auto-reported. */ - 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") } - } + @RequiresApi(Build.VERSION_CODES.R) + private val POST_MORTEM_REASONS: Set = setOf( + ApplicationExitInfo.REASON_ANR, + ApplicationExitInfo.REASON_CRASH, + ApplicationExitInfo.REASON_CRASH_NATIVE, + /*ApplicationExitInfo.REASON_SIGNALED, + ApplicationExitInfo.REASON_LOW_MEMORY, + ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE,*/ + ) } From ff724778f154929428b0463b5c8837527832911f Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Mon, 13 Jul 2026 22:36:42 +0200 Subject: [PATCH 31/35] Differentiate system tags in yellow from the other tags when they are disabled. --- .../main/feature/settings/debug/DebugScreen.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index ddecccdb1..f8569df9a 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -1453,9 +1453,21 @@ private fun SelectableChannelChip( onToggle: () -> Unit, tint: Color = Accent, ) { + // This should be changed if tint is used by other chips in the future. + val isSystemTag = tint == Accent + 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 + // If it's a system tag, show a subtle tinted border and text even when unselected + val borderColor = when { + isSelected -> tint.copy(alpha = 0.55f) + isSystemTag -> tint.copy(alpha = 0.35f) // Subtle yellow border for system tags + else -> CardBorder + } + val textColor = when { + isSelected -> tint + isSystemTag -> tint.copy(alpha = 0.85f) // Dimmed yellow text for system tags + else -> TextPrimary + } Box( modifier = Modifier From 51e6f23554337abf73abab2d84457a60c0ec565d Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Tue, 14 Jul 2026 13:43:20 +0200 Subject: [PATCH 32/35] Fixed Event Watch log not returning logs in some cases. - Thanks to Xnick417x for pinpointing exactly the cause that had eluded me regarding the bug. - Improved the logic for distinguishing between system tags and app tags in DebugScreen to make it more accurate. - Restored the logs that can be used to verify the OOM priority of the Wine processes when HeartBeat and Event Watch are enabled. --- .../feature/settings/debug/DebugScreen.kt | 6 +-- app/src/main/runtime/system/LogManager.kt | 52 ++++++++----------- .../main/runtime/system/ProcessHelper.java | 25 +++++++-- 3 files changed, 44 insertions(+), 39 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index f8569df9a..cfcd18697 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -157,6 +157,7 @@ private val Warning = Color(0xFFFF4444) private val Success = Color(0xFF7CC142) private val TextPrimary = Color(0xFFF0F4FF) private val TextSecondary = Color(0xFF7A8FA8) +private val SystemTagColor = Color(0xFFFFCC00) // State data class DebugState( @@ -1433,7 +1434,7 @@ private fun ColumnScope.ChannelGrid( ) { itemsIndexed(options) { index, channel -> val isSystem = channel in systemTags - val chipAccent = if (isSystem) Color(0xFFFFCC00) else Accent + val chipAccent = if (isSystem) SystemTagColor else Accent SelectableChannelChip( label = channel, isSelected = channel in selected, @@ -1453,8 +1454,7 @@ private fun SelectableChannelChip( onToggle: () -> Unit, tint: Color = Accent, ) { - // This should be changed if tint is used by other chips in the future. - val isSystemTag = tint == Accent + val isSystemTag = tint == SystemTagColor val bg = if (isSelected) tint.copy(alpha = 0.18f) else IconBoxBg // If it's a system tag, show a subtle tinted border and text even when unselected diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index cfab00351..3bfb55424 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -59,7 +59,7 @@ object LogManager { // or tags used only in rarely-executed paths). private val DEVELOPER_TAGS: Set = setOf( "WinlatorLifecycle", - "WineOomProtect", + "OomProtectCheck", "GuestProgramLauncherComponent", "XServerLeakCheck", ) @@ -105,6 +105,8 @@ object LogManager { /** Cheap, public, and the recommended guard for any genuinely expensive log message. */ @JvmStatic val isDebugEnabled: Boolean get() = cachedAppDebugEnabled + @JvmStatic + val isEventWatchEnabled: Boolean get() = cachedEventWatchEnabled private var crashHandlerInitialized = false @@ -583,44 +585,32 @@ object LogManager { 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 -> { - // 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") - } - } + // Wildcard first as the default floor; explicit baseline rules follow + // so they take precedence and elevate those tags to their native level. + // No :S rules anywhere — ALL mode never suppresses anything. spec.add("*:D") - excluded.forEach { spec.add("$it:S") } - } - 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") - selectedApp.forEach { spec.add("$it:S") } + BASELINE_SYSTEM_TAGS.forEach { (tag, priority) -> spec.add("$tag:$priority") } } TagFilterMode.INCLUDE -> { - selectedApp.forEach { spec.add("$it:D") } + // Wildcard first to suppress everything; selected tags follow to + // un-suppress themselves by overriding the wildcard. spec.add("*:S") + selectedBaseline.forEach { tag -> spec.add("$tag:${BASELINE_SYSTEM_TAGS[tag]}") } + selectedApp.forEach { tag -> spec.add("$tag:D") } + } + TagFilterMode.EXCLUDE -> { + // Wildcard first to allow everything; excluded tags follow to suppress + // themselves, non-excluded baseline tags follow to elevate to native level. + spec.add("*:D") + BASELINE_SYSTEM_TAGS.forEach { (tag, priority) -> + if (tag in selectedBaseline) spec.add("$tag:S") + else spec.add("$tag:$priority") + } + selectedApp.forEach { tag -> spec.add("$tag:S") } } } - return spec } diff --git a/app/src/main/runtime/system/ProcessHelper.java b/app/src/main/runtime/system/ProcessHelper.java index 3612d3adc..e669171a5 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 = "WineOomProtect"; + private static final String OOM_TAG = "OomProtectCheck"; public static native int reapDeadChildrenNow(); @@ -244,10 +244,25 @@ private static boolean isCoreProcess(String normalizedData) { } public static void protectAllWineProcesses() { - ArrayList processes = listRunningWineProcesses(); - for (String process : processes) { - setOomScoreAdj(Integer.parseInt(process), OOM_SCORE_ADJ_PROTECT); - } + ArrayList processes = listRunningWineProcesses(); + boolean eventWatchActive = LogManager.isEventWatchEnabled(); + for (String process : processes) { + if (eventWatchActive) { + String 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. + if (eventWatchActive) { + String 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() { From 4ccb21e8e6516f934548dbcc3f3ab90c910081f8 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Tue, 14 Jul 2026 17:57:40 +0200 Subject: [PATCH 33/35] Fix: The crash log generated with DefaultUncaughtExceptionHandler was not creating a log file. A silly bug, introduced by a mental slip while trying to make the generated log file name look better, has been fixed. --- app/src/main/runtime/system/LogManager.kt | 4 ++-- app/src/main/runtime/system/SessionKeepAliveService.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 3bfb55424..63a66b9fe 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -551,7 +551,7 @@ object LogManager { 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 stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date()) val file = File(getLogsDir(context), "event_${safeLabel}_$stamp.log") appendLine(context, file.name, "I/$TAG", "=== event watch started ($safeLabel) ===") @@ -741,7 +741,7 @@ object LogManager { @JvmStatic fun logCrash(context: Context, thread: Thread, throwable: Throwable) { try { - val timestamp = SimpleDateFormat("yyyy-MM-dd_HH:mm:ss_SSS", Locale.US).format(Date()) + 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) diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 88030818f..b9dbfa998 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -405,7 +405,7 @@ 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()); // Disabled because container "Exit" causes too much issues. +// boolean showExit = isAppVisible() && (containerActive || PrefManager.INSTANCE.getChatStayRunningOnExit()); // Disabled because container "Exit" causes too much issues, ANR crash for example. boolean showExit = isAppVisible() && PrefManager.INSTANCE.getChatStayRunningOnExit(); // Determine target activity: Game screen if active, else Main menu From 7c0119154953e68118c75c2a05935cdfc48c3e96 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Tue, 14 Jul 2026 18:12:37 +0200 Subject: [PATCH 34/35] Updated translations with the new strings. - Thanks to Xnick417x for the patch with the translations. --- app/src/main/res/values-b+es+419/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-da/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-de/strings.xml | 28 +++++++++++++ app/src/main/res/values-es/strings.xml | 28 +++++++++++++ app/src/main/res/values-fi/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-fr/strings.xml | 28 +++++++++++++ app/src/main/res/values-hi/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-it/strings.xml | 28 +++++++++++++ app/src/main/res/values-ja/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-ko/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-no/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-pl/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-pt-rBR/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-pt/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-ro/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-ru/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-sv/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-th/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-tr/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-uk/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-zh-rCN/strings.xml | 44 ++++++++++++++++++++ app/src/main/res/values-zh-rTW/strings.xml | 44 ++++++++++++++++++++ 22 files changed, 904 insertions(+) diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 3b6a60778..511fd1fd5 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1562,4 +1562,48 @@ Ruta instalada: La creación de la carpeta falló Actualizar + + + Registro de motivo de salida + Registra por qué terminó el proceso de la aplicación + Registro de fallos + Guardar registros de fallos nativos (tombstone) + Registro de observación de eventos + Captura un registro del sistema centrado en los eventos de pausa/reanudación + Filtro de etiquetas de registro + etiquetas seleccionadas + Filtrar líneas de registro por texto… + Modo + Todas + Incluir + Excluir + Agregar etiqueta personalizada… + Ninguna etiqueta seleccionada + + Protección en segundo plano + Mantener la sesión activa en segundo plano + Modo de pausa en segundo plano + Pausar todos los procesos + Suspende todos los procesos — máximo ahorro de batería (puede causar problemas). + Pausar solo el juego + Suspende solo el proceso del juego activo; los demás procesos y servicios siguen ejecutándose. + Pausar todo excepto el juego + Suspende todos los procesos y servicios, pero mantiene activo el proceso del juego. + Pausar sesión automáticamente + Pausa automáticamente la sesión cuando la aplicación pasa a segundo plano o el dispositivo se bloquea. + Activar un wake lock de CPU para mejorar la protección en segundo plano + Evita que la CPU entre en suspensión profunda mientras la sesión está en pausa. Aumenta el consumo de batería, pero puede mejorar la persistencia y la estabilidad en dispositivos con gestión de batería agresiva. + Frecuencia del heartbeat (segundos) + Puede mejorar la protección de la aplicación en segundo plano. Un valor de 0 desactiva la función. Cuanto menor sea el valor, mayor será el consumo potencial de batería. + Heartbeat desactivado + El mínimo es 5 s — se ajustará al guardar + Se ejecuta cada %1$d s + + La sesión del contenedor está en pausa + Hay una sesión de contenedor en ejecución + Descargando e instalando componentes + Chat de Steam ejecutándose en segundo plano + : servicio activo + : servicios activos + y diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 535cf118c..ba83911ab 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1566,4 +1566,48 @@ Installeret sti: Kør i baggrunden Hold chatten kørende, efter du afslutter WinNative Opdater + + + Log over lukningsårsag + Registrerer, hvorfor appens proces sidst blev afsluttet + Nedbrudslog + Gem native nedbruds-spor (tombstone) + Hændelsesovervågningslog + Optager en fokuseret systemlog omkring pause/genoptag-hændelser + Logtag-filter + tags valgt + Filtrer loglinjer efter tekst… + Tilstand + Alle + Inkluder + Ekskluder + Tilføj brugerdefineret tag… + Ingen tags valgt + + Baggrundsbeskyttelse + Hold sessionen i live i baggrunden + Pausetilstand i baggrunden + Sæt alle processer på pause + Suspenderer alle processer — maksimal batteribesparelse (kan give problemer). + Sæt kun spillet på pause + Suspenderer kun den aktive spilproces; andre processer og tjenester kører videre. + Sæt alt undtagen spillet på pause + Suspenderer alle processer og tjenester, men holder spilprocessen aktiv. + Automatisk pause af session + Sætter automatisk sessionen på pause, når appen er i baggrunden, eller enheden er låst. + Aktivér en CPU-wakelock for at forbedre baggrundsbeskyttelsen + Forhindrer CPU\'en i at gå i dyb dvale, mens sessionen er på pause. Øger batteriforbruget, men kan forbedre persistensen og stabiliteten på enheder med aggressiv batteristyring. + Heartbeat-frekvens (sekunder) + Kan forbedre beskyttelsen af appen i baggrunden. Værdien 0 deaktiverer funktionen. Jo lavere værdi, desto højere potentielt batteriforbrug. + Heartbeat deaktiveret + Minimum er 5 s — justeres ved gemning + Kører hver %1$d s + + Containersessionen er på pause + En containersession kører + Downloader og installerer komponenter + Steam-chat kører i baggrunden + : tjeneste aktiv + : tjenester aktive + og diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e5bceaf42..80fd2008d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1583,4 +1583,32 @@ Installierter Pfad: Im Hintergrund ausführen Chat nach dem Beenden von WinNative weiterlaufen lassen Aktualisieren + + + Hintergrundschutz + Sitzung im Hintergrund aktiv halten + Pausenmodus im Hintergrund + Alle Prozesse pausieren + Hält alle Prozesse an — maximale Akku-Ersparnis (kann Probleme verursachen). + Nur das Spiel pausieren + Hält nur den aktiven Spielprozess an; alle anderen Prozesse und Dienste laufen weiter. + Alles außer dem Spiel pausieren + Hält alle Prozesse und Dienste an, lässt den Spielprozess aber aktiv. + Sitzung automatisch pausieren + Pausiert die Sitzung automatisch, wenn die App in den Hintergrund wechselt oder das Gerät gesperrt wird. + CPU-Wakelock aktivieren, um den Hintergrundschutz zu verbessern + Verhindert den CPU-Tiefschlaf, während die Sitzung pausiert ist. Erhöht den Akkuverbrauch, kann aber die Persistenz und Stabilität auf Geräten mit aggressivem Akku-Management verbessern. + Heartbeat-Frequenz (Sekunden) + Kann den Hintergrundschutz der App verbessern. Der Wert 0 deaktiviert die Funktion. Je niedriger der Wert, desto höher der mögliche Akkuverbrauch. + Heartbeat deaktiviert + Minimum ist 5 s — wird beim Speichern angepasst + Läuft alle %1$d s + + Container-Sitzung ist pausiert + Eine Container-Sitzung läuft + Komponenten werden heruntergeladen und installiert + Steam-Chat läuft im Hintergrund + : Dienst aktiv + : Dienste aktiv + und diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 38fec1f12..6a44ce8e7 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1583,5 +1583,33 @@ Ruta instalada: Ejecutar en segundo plano Mantener el chat activo después de salir de WinNative Actualizar + + + Protección en segundo plano + Mantener la sesión activa en segundo plano + Modo de pausa en segundo plano + Pausar todos los procesos + Suspende todos los procesos — máximo ahorro de batería (puede causar problemas). + Pausar solo el juego + Suspende solo el proceso del juego activo; los demás procesos y servicios siguen ejecutándose. + Pausar todo excepto el juego + Suspende todos los procesos y servicios, pero mantiene activo el proceso del juego. + Pausar sesión automáticamente + Pausa automáticamente la sesión cuando la aplicación pasa a segundo plano o el dispositivo se bloquea. + Activar un wake lock de CPU para mejorar la protección en segundo plano + Evita que la CPU entre en suspensión profunda mientras la sesión está en pausa. Aumenta el consumo de batería, pero puede mejorar la persistencia y la estabilidad en dispositivos con gestión de batería agresiva. + Frecuencia del heartbeat (segundos) + Puede mejorar la protección de la aplicación en segundo plano. Un valor de 0 desactiva la función. Cuanto menor sea el valor, mayor será el consumo potencial de batería. + Heartbeat desactivado + El mínimo es 5 s — se ajustará al guardar + Se ejecuta cada %1$d s + + La sesión del contenedor está en pausa + Hay una sesión de contenedor en ejecución + Descargando e instalando componentes + Chat de Steam ejecutándose en segundo plano + : servicio activo + : servicios activos + y diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index dfe438791..7046f3848 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1562,4 +1562,48 @@ Asennuspolku: Kansion luonti epäonnistui Päivitä + + + Sulkeutumissyyloki + Tallentaa syyn, miksi sovelluksen prosessi viimeksi päättyi + Kaatumisloki + Tallenna natiivien kaatumisten jäljet (tombstone) + Tapahtumien valvontaloki + Tallentaa kohdennetun järjestelmälokin keskeytys-/jatkamistapahtumien ympäriltä + Lokitunnisteiden suodatin + tunnistetta valittu + Suodata lokirivejä tekstillä… + Tila + Kaikki + Sisällytä + Jätä pois + Lisää oma tunniste… + Ei valittuja tunnisteita + + Taustasuojaus + Pidä istunto käynnissä taustalla + Taustan keskeytystila + Keskeytä kaikki prosessit + Pysäyttää kaikki prosessit — suurin virransäästö (voi aiheuttaa ongelmia). + Keskeytä vain peli + Pysäyttää vain aktiivisen pelin prosessin; muut prosessit ja palvelut jatkavat toimintaansa. + Keskeytä kaikki paitsi peli + Pysäyttää kaikki prosessit ja palvelut, mutta pitää pelin prosessin aktiivisena. + Keskeytä istunto automaattisesti + Keskeyttää istunnon automaattisesti, kun sovellus siirtyy taustalle tai laite lukitaan. + Ota käyttöön suorittimen wake lock taustasuojauksen parantamiseksi + Estää suoritinta siirtymästä syväuneen istunnon ollessa keskeytettynä. Lisää akun kulutusta, mutta voi parantaa pysyvyyttä ja vakautta laitteissa, joissa on aggressiivinen akunhallinta. + Heartbeat-taajuus (sekuntia) + Voi parantaa sovelluksen taustasuojausta. Arvo 0 poistaa ominaisuuden käytöstä. Mitä pienempi arvo, sitä suurempi mahdollinen akun kulutus. + Heartbeat pois käytöstä + Vähimmäisarvo on 5 s — korjataan tallennettaessa + Suoritetaan %1$d sekunnin välein + + Säiliöistunto on keskeytetty + Säiliöistunto on käynnissä + Ladataan ja asennetaan komponentteja + Steam-chat käynnissä taustalla + : palvelu käynnissä + : palvelut käynnissä + ja diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 76ede9c86..fd4c0dc6d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1583,5 +1583,33 @@ Chemin installé : Exécuter en arrière-plan Garder le chat actif après avoir quitté WinNative Actualiser + + + Protection en arrière-plan + Maintenir la session active en arrière-plan + Mode de pause en arrière-plan + Mettre en pause tous les processus + Suspend tous les processus — économie de batterie maximale (peut causer des problèmes). + Mettre en pause uniquement le jeu + Suspend uniquement le processus du jeu actif ; les autres processus et services continuent de fonctionner. + Tout mettre en pause sauf le jeu + Suspend tous les processus et services, mais garde le processus du jeu actif. + Pause automatique de la session + Met automatiquement la session en pause lorsque l\'application passe en arrière-plan ou que l\'appareil est verrouillé. + Activer un wake lock CPU pour renforcer la protection en arrière-plan + Empêche le processeur d\'entrer en veille profonde pendant que la session est en pause. Augmente la consommation de batterie, mais peut améliorer la persistance et la stabilité sur les appareils à gestion de batterie agressive. + Fréquence du heartbeat (secondes) + Peut améliorer la protection de l\'application en arrière-plan. Une valeur de 0 désactive la fonction. Plus la valeur est basse, plus la consommation de batterie potentielle est élevée. + Heartbeat désactivé + Le minimum est de 5 s — sera ajusté à l\'enregistrement + S\'exécute toutes les %1$d s + + La session du conteneur est en pause + Une session de conteneur est en cours + Téléchargement et installation de composants + Chat Steam actif en arrière-plan + " : service actif" + " : services actifs" + et diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index f0f8d5518..9692e8138 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1501,4 +1501,48 @@ बैकग्राउंड में चलाएँ WinNative से बाहर निकलने के बाद चैट चालू रखें रीफ़्रेश + + + बंद होने के कारण का लॉग + रिकॉर्ड करता है कि ऐप प्रोसेस पिछली बार क्यों बंद हुई + क्रैश लॉग + नेटिव क्रैश टॉम्बस्टोन ट्रेस सहेजें + इवेंट निगरानी लॉग + पॉज़/रिज़्यूम इवेंट के आसपास केंद्रित सिस्टम लॉग रिकॉर्ड करता है + लॉग टैग फ़िल्टर + टैग चुने गए + टेक्स्ट से लॉग पंक्तियाँ फ़िल्टर करें… + मोड + सभी + शामिल करें + बाहर रखें + कस्टम टैग जोड़ें… + कोई टैग नहीं चुना गया + + बैकग्राउंड सुरक्षा + बैकग्राउंड में सेशन चालू रखें + बैकग्राउंड पॉज़ मोड + सभी प्रोसेस पॉज़ करें + सभी प्रोसेस निलंबित करता है — अधिकतम बैटरी बचत (समस्याएँ हो सकती हैं)। + केवल गेम पॉज़ करें + केवल चालू गेम प्रोसेस निलंबित करता है; बाकी प्रोसेस और सेवाएँ चलती रहती हैं। + गेम को छोड़कर सब पॉज़ करें + सभी प्रोसेस और सेवाएँ निलंबित करता है, लेकिन गेम प्रोसेस चालू रखता है। + सेशन स्वतः पॉज़ करें + ऐप बैकग्राउंड में जाने या डिवाइस लॉक होने पर सेशन को स्वतः पॉज़ करता है। + बैकग्राउंड सुरक्षा बढ़ाने के लिए CPU वेक लॉक सक्षम करें + सेशन पॉज़ रहते हुए CPU को डीप स्लीप में जाने से रोकता है। बैटरी खपत बढ़ती है, लेकिन आक्रामक बैटरी प्रबंधन वाले डिवाइस पर स्थायित्व और स्थिरता बेहतर हो सकती है। + हार्टबीट आवृत्ति (सेकंड) + बैकग्राउंड में ऐप की सुरक्षा बेहतर कर सकता है। 0 मान इस सुविधा को बंद कर देता है। मान जितना कम होगा, बैटरी खपत उतनी अधिक हो सकती है। + हार्टबीट बंद है + न्यूनतम 5 से. है — सहेजते समय ठीक कर दिया जाएगा + हर %1$d से. पर चलता है + + कंटेनर सेशन पॉज़ है + एक कंटेनर सेशन चल रहा है + कॉम्पोनेन्ट डाउनलोड और इंस्टॉल हो रहे हैं + Steam चैट बैकग्राउंड में चल रही है + : सेवा सक्रिय है + : सेवाएँ सक्रिय हैं + और diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 637f45816..c86811811 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1583,5 +1583,33 @@ Percorso installato: Esegui in background Mantieni la chat attiva dopo aver chiuso WinNative Aggiorna + + + Protezione in background + Mantieni la sessione attiva in background + Modalità di pausa in background + Metti in pausa tutti i processi + Sospende tutti i processi — massimo risparmio della batteria (può causare problemi). + Metti in pausa solo il gioco + Sospende solo il processo del gioco attivo; gli altri processi e servizi continuano a funzionare. + Metti in pausa tutto tranne il gioco + Sospende tutti i processi e i servizi, ma mantiene attivo il processo del gioco. + Pausa automatica della sessione + Mette automaticamente in pausa la sessione quando l\'app è in background o il dispositivo è bloccato. + Abilita un wake lock della CPU per migliorare la protezione in background + Impedisce alla CPU di entrare in sospensione profonda mentre la sessione è in pausa. Aumenta il consumo della batteria, ma può migliorare la persistenza e la stabilità sui dispositivi con gestione aggressiva della batteria. + Frequenza dell\'heartbeat (secondi) + Può migliorare la protezione dell\'app in background. Un valore di 0 disattiva la funzione. Più basso è il valore, maggiore è il potenziale consumo della batteria. + Heartbeat disattivato + Il minimo è 5 s — verrà corretto al salvataggio + Viene eseguito ogni %1$d s + + La sessione del contenitore è in pausa + È in esecuzione una sessione del contenitore + Download e installazione dei componenti in corso + Chat di Steam in esecuzione in background + : servizio attivo + : servizi attivi + e diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 851fe22c0..55cb78237 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1562,4 +1562,48 @@ フォルダの作成に失敗しました 更新 + + + 終了理由ログ + アプリのプロセスが前回終了した理由を記録します + クラッシュログ + ネイティブクラッシュの tombstone トレースを保存します + イベント監視ログ + 一時停止/再開イベント前後のシステムログを記録します + ログタグフィルター + 個のタグを選択中 + テキストでログ行をフィルター… + モード + すべて + 含める + 除外 + カスタムタグを追加… + タグが選択されていません + + バックグラウンド保護 + バックグラウンドでセッションを維持します + バックグラウンド一時停止モード + すべてのプロセスを一時停止 + すべてのプロセスを停止します — 最大限のバッテリー節約(問題が発生する可能性があります)。 + ゲームのみ一時停止 + 実行中のゲームプロセスのみ停止し、他のプロセスやサービスは動作し続けます。 + ゲーム以外をすべて一時停止 + すべてのプロセスとサービスを停止しますが、ゲームプロセスは動作し続けます。 + セッションの自動一時停止 + アプリがバックグラウンドに移行するか端末がロックされたとき、セッションを自動的に一時停止します。 + CPU ウェイクロックを有効にしてバックグラウンド保護を強化 + セッションの一時停止中に CPU のディープスリープを防ぎます。バッテリー消費は増えますが、電池管理が厳しい端末での持続性と安定性が向上する場合があります。 + ハートビート頻度(秒) + バックグラウンドでのアプリ保護を改善できます。0 を設定すると無効になります。値が小さいほどバッテリー消費が増える可能性があります。 + ハートビート無効 + 最小値は 5 秒です — 保存時に補正されます + %1$d 秒ごとに実行 + + コンテナセッションは一時停止中です + コンテナセッションが実行中です + コンポーネントをダウンロードしてインストール中 + Steam チャットがバックグラウンドで実行中 + : サービス実行中 + : サービス実行中 + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 642d49735..cdec816cb 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1566,5 +1566,49 @@ 백그라운드에서 실행 WinNative를 종료한 후에도 채팅 유지 새로고침 + + + 종료 원인 로그 + 앱 프로세스가 마지막으로 종료된 이유를 기록합니다 + 충돌 로그 + 네이티브 충돌 tombstone 트레이스를 저장합니다 + 이벤트 감시 로그 + 일시정지/재개 이벤트 전후의 시스템 로그를 기록합니다 + 로그 태그 필터 + 개 태그 선택됨 + 텍스트로 로그 줄 필터링… + 모드 + 전체 + 포함 + 제외 + 사용자 지정 태그 추가… + 선택된 태그 없음 + + 백그라운드 보호 + 백그라운드에서 세션 유지 + 백그라운드 일시정지 모드 + 모든 프로세스 일시정지 + 모든 프로세스를 정지합니다 — 최대 배터리 절약 (문제가 발생할 수 있음). + 게임만 일시정지 + 실행 중인 게임 프로세스만 정지하고, 다른 프로세스와 서비스는 계속 실행됩니다. + 게임 제외 모두 일시정지 + 모든 프로세스와 서비스를 정지하지만 게임 프로세스는 계속 실행됩니다. + 세션 자동 일시정지 + 앱이 백그라운드로 전환되거나 기기가 잠기면 세션을 자동으로 일시정지합니다. + CPU 웨이크락을 활성화하여 백그라운드 보호 강화 + 세션이 일시정지된 동안 CPU의 딥슬립을 방지합니다. 배터리 사용량이 늘어나지만, 배터리 관리가 공격적인 기기에서 지속성과 안정성이 향상될 수 있습니다. + 하트비트 주기(초) + 백그라운드 앱 보호를 개선할 수 있습니다. 값이 0이면 기능이 비활성화됩니다. 값이 낮을수록 배터리 소모가 커질 수 있습니다. + 하트비트 비활성화됨 + 최솟값은 5초이며 저장 시 자동 조정됩니다 + %1$d초마다 실행 + + 컨테이너 세션이 일시정지됨 + 컨테이너 세션이 실행 중입니다 + 구성 요소 다운로드 및 설치 중 + Steam 채팅이 백그라운드에서 실행 중 + : 서비스 실행 중 + : 서비스 실행 중 + diff --git a/app/src/main/res/values-no/strings.xml b/app/src/main/res/values-no/strings.xml index 11f41be1d..3f10fe7b8 100644 --- a/app/src/main/res/values-no/strings.xml +++ b/app/src/main/res/values-no/strings.xml @@ -1562,4 +1562,48 @@ Installert bane: Oppretting av mappe mislyktes Oppdater + + + Logg over avslutningsårsak + Registrerer hvorfor appens prosess sist ble avsluttet + Krasjlogg + Lagre native krasj-spor (tombstone) + Hendelsesovervåkingslogg + Fanger en fokusert systemlogg rundt pause/gjenoppta-hendelser + Loggtagg-filter + tagger valgt + Filtrer logglinjer etter tekst… + Modus + Alle + Inkluder + Ekskluder + Legg til egendefinert tagg… + Ingen tagger valgt + + Bakgrunnsbeskyttelse + Hold økten i live i bakgrunnen + Pausemodus i bakgrunnen + Sett alle prosesser på pause + Suspenderer alle prosesser — maksimal batterisparing (kan gi problemer). + Sett bare spillet på pause + Suspenderer bare den aktive spillprosessen; andre prosesser og tjenester fortsetter å kjøre. + Sett alt unntatt spillet på pause + Suspenderer alle prosesser og tjenester, men holder spillprosessen aktiv. + Automatisk pause av økten + Setter økten automatisk på pause når appen går i bakgrunnen eller enheten låses. + Aktiver en CPU-wakelock for å styrke bakgrunnsbeskyttelsen + Hindrer CPU-en i å gå i dyp dvale mens økten er på pause. Øker batteriforbruket, men kan forbedre persistensen og stabiliteten på enheter med aggressiv batteristyring. + Heartbeat-frekvens (sekunder) + Kan forbedre beskyttelsen av appen i bakgrunnen. Verdien 0 deaktiverer funksjonen. Jo lavere verdi, desto høyere potensielt batteriforbruk. + Heartbeat deaktivert + Kjører hver %1$d s + Minimum er 5 s — justeres ved lagring + + Containerøkten er satt på pause + En containerøkt kjører + Laster ned og installerer komponenter + Steam-chat kjører i bakgrunnen + : tjeneste aktiv + : tjenester aktive + og diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 1098113cf..f97333731 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1571,5 +1571,49 @@ Zainstalowana ścieżka: Uruchom w tle Pozostaw czat aktywny po zamknięciu WinNative Odśwież + + + Log przyczyny zamknięcia + Zapisuje, dlaczego proces aplikacji został ostatnio zakończony + Log awarii + Zapisuj ślady natywnych awarii (tombstone) + Log obserwacji zdarzeń + Rejestruje log systemowy wokół zdarzeń pauzy/wznowienia + Filtr tagów logu + wybranych tagów + Filtruj linie logu po tekście… + Tryb + Wszystkie + Uwzględnij + Wyklucz + Dodaj własny tag… + Nie wybrano tagów + + Ochrona w tle + Utrzymuj sesję aktywną w tle + Tryb pauzy w tle + Wstrzymaj wszystkie procesy + Wstrzymuje wszystkie procesy — maksymalna oszczędność baterii (może powodować problemy). + Wstrzymaj tylko grę + Wstrzymuje tylko proces aktywnej gry; pozostałe procesy i usługi działają dalej. + Wstrzymaj wszystko oprócz gry + Wstrzymuje wszystkie procesy i usługi, ale pozostawia proces gry aktywny. + Automatyczna pauza sesji + Automatycznie wstrzymuje sesję, gdy aplikacja przechodzi w tło lub urządzenie jest zablokowane. + Włącz wake lock CPU, aby wzmocnić ochronę w tle + Zapobiega przechodzeniu procesora w głęboki sen, gdy sesja jest wstrzymana. Zwiększa zużycie baterii, ale może poprawić trwałość i stabilność na urządzeniach z agresywnym zarządzaniem baterią. + Częstotliwość heartbeat (sekundy) + Może poprawić ochronę aplikacji w tle. Wartość 0 wyłącza funkcję. Im niższa wartość, tym większe potencjalne zużycie baterii. + Heartbeat wyłączony + Minimum to 5 s — zostanie skorygowane przy zapisie + Wykonuje się co %1$d s + + Sesja kontenera jest wstrzymana + Trwa sesja kontenera + Pobieranie i instalowanie komponentów + Czat Steam działa w tle + : usługa aktywna + : usługi aktywne + i diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a81e2c8b8..2f99f53e6 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1565,5 +1565,49 @@ Caminho instalado: Executar em segundo plano Manter o chat ativo após sair do WinNative Atualizar + + + Log de motivo de encerramento + Registra por que o processo do aplicativo foi encerrado da última vez + Log de falhas + Salvar logs de falhas nativas (tombstone) + Log de observação de eventos + Captura um log do sistema focado nos eventos de pausa/retomada + Filtro de tags de log + tags selecionadas + Filtrar linhas do log por texto… + Modo + Todas + Incluir + Excluir + Adicionar tag personalizada… + Nenhuma tag selecionada + + Proteção em segundo plano + Manter a sessão ativa em segundo plano + Modo de pausa em segundo plano + Pausar todos os processos + Suspende todos os processos — máxima economia de bateria (pode causar problemas). + Pausar apenas o jogo + Suspende apenas o processo do jogo ativo; os demais processos e serviços continuam em execução. + Pausar tudo, exceto o jogo + Suspende todos os processos e serviços, mas mantém o processo do jogo ativo. + Pausar sessão automaticamente + Pausa automaticamente a sessão quando o aplicativo vai para segundo plano ou o dispositivo é bloqueado. + Ativar um wake lock da CPU para reforçar a proteção em segundo plano + Impede que a CPU entre em suspensão profunda enquanto a sessão está pausada. Aumenta o consumo de bateria, mas pode melhorar a persistência e a estabilidade em aparelhos com gerenciamento agressivo de bateria. + Frequência do heartbeat (segundos) + Pode melhorar a proteção do aplicativo em segundo plano. Um valor de 0 desativa o recurso. Quanto menor o valor, maior o consumo potencial de bateria. + Heartbeat desativado + O mínimo é 5 s — será ajustado ao salvar + Executa a cada %1$d s + + A sessão do container está pausada + Há uma sessão de container em execução + Baixando e instalando componentes + Chat do Steam em execução em segundo plano + : serviço ativo + : serviços ativos + e diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 31d08370d..a533be02e 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1562,4 +1562,48 @@ Caminho instalado: A criação da pasta falhou Atualizar + + + Registo do motivo de encerramento + Regista porque é que o processo da aplicação terminou da última vez + Registo de falhas + Guardar registos de falhas nativas (tombstone) + Registo de observação de eventos + Captura um registo do sistema centrado nos eventos de pausa/retoma + Filtro de etiquetas de registo + etiquetas selecionadas + Filtrar linhas do registo por texto… + Modo + Todas + Incluir + Excluir + Adicionar etiqueta personalizada… + Nenhuma etiqueta selecionada + + Proteção em segundo plano + Manter a sessão ativa em segundo plano + Modo de pausa em segundo plano + Pausar todos os processos + Suspende todos os processos — máxima poupança de bateria (pode causar problemas). + Pausar apenas o jogo + Suspende apenas o processo do jogo ativo; os restantes processos e serviços continuam em execução. + Pausar tudo exceto o jogo + Suspende todos os processos e serviços, mas mantém o processo do jogo ativo. + Pausar sessão automaticamente + Pausa automaticamente a sessão quando a aplicação passa para segundo plano ou o dispositivo é bloqueado. + Ativar um wake lock da CPU para reforçar a proteção em segundo plano + Impede que a CPU entre em suspensão profunda enquanto a sessão está em pausa. Aumenta o consumo de bateria, mas pode melhorar a persistência e a estabilidade em dispositivos com gestão agressiva de bateria. + Frequência do heartbeat (segundos) + Pode melhorar a proteção da aplicação em segundo plano. Um valor de 0 desativa a funcionalidade. Quanto menor o valor, maior o potencial consumo de bateria. + Heartbeat desativado + O mínimo é 5 s — será ajustado ao guardar + Executa a cada %1$d s + + A sessão do contentor está em pausa + Há uma sessão de contentor em execução + A transferir e instalar componentes + Chat do Steam em execução em segundo plano + : serviço ativo + : serviços ativos + e diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index a60b885a9..8adc8d20c 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1564,5 +1564,49 @@ Cale instalata: Rulează în fundal Menține chatul activ după ce ieși din WinNative Reîmprospătează + + + Jurnal motiv de închidere + Înregistrează de ce s-a încheiat ultima dată procesul aplicației + Jurnal de blocări + Salvează urmele native ale blocărilor (tombstone) + Jurnal de monitorizare a evenimentelor + Capturează un jurnal de sistem concentrat pe evenimentele de pauză/reluare + Filtru de etichete de jurnal + etichete selectate + Filtrează liniile de jurnal după text… + Mod + Toate + Include + Exclude + Adaugă etichetă personalizată… + Nicio etichetă selectată + + Protecție în fundal + Menține sesiunea activă în fundal + Mod de pauză în fundal + Pune pauză tuturor proceselor + Suspendă toate procesele — economie maximă de baterie (poate cauza probleme). + Pune pauză doar jocului + Suspendă doar procesul jocului activ; celelalte procese și servicii continuă să ruleze. + Pune pauză la tot, cu excepția jocului + Suspendă toate procesele și serviciile, dar menține procesul jocului activ. + Pauză automată a sesiunii + Pune automat sesiunea pe pauză când aplicația trece în fundal sau dispozitivul este blocat. + Activează un wake lock CPU pentru a întări protecția în fundal + Împiedică CPU-ul să intre în somn profund cât timp sesiunea este pe pauză. Crește consumul de baterie, dar poate îmbunătăți persistența și stabilitatea pe dispozitivele cu gestionare agresivă a bateriei. + Frecvența heartbeat (secunde) + Poate îmbunătăți protecția aplicației în fundal. Valoarea 0 dezactivează funcția. Cu cât valoarea este mai mică, cu atât consumul potențial de baterie este mai mare. + Heartbeat dezactivat + Minimul este 5 s — va fi corectat la salvare + Rulează la fiecare %1$d s + + Sesiunea containerului este pe pauză + O sesiune de container rulează + Se descarcă și se instalează componente + Chatul Steam rulează în fundal + : serviciu activ + : servicii active + și diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1a89a3a99..5d471159d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1471,4 +1471,48 @@ Работать в фоне Оставлять чат активным после выхода из WinNative Обновить + + + Журнал причин завершения + Записывать причину последнего завершения процесса приложения + Журнал сбоев + Сохранять трассировки нативных сбоев (tombstone) + Журнал наблюдения за событиями + Записывать системный журнал вокруг событий паузы/возобновления + Фильтр тегов журнала + тегов выбрано + Фильтровать строки журнала по тексту… + Режим + Все + Включать + Исключать + Добавить свой тег… + Теги не выбраны + + Защита в фоновом режиме + Сохранять сессию активной в фоновом режиме + Режим паузы в фоне + Приостанавливать все процессы + Приостанавливает все процессы — максимальная экономия батареи (возможны проблемы). + Приостанавливать только игру + Приостанавливает только процесс активной игры; остальные процессы и службы продолжают работать. + Приостанавливать всё, кроме игры + Приостанавливает все процессы и службы, но оставляет процесс игры активным. + Автопауза сессии + Автоматически приостанавливать сессию, когда приложение уходит в фон или устройство заблокировано. + Включить wake lock ЦП для усиления защиты в фоне + Не даёт ЦП уходить в глубокий сон, пока сессия на паузе. Увеличивает расход батареи, но может улучшить персистентность и стабильность на устройствах с агрессивной оптимизацией батареи. + Частота heartbeat (секунды) + Может улучшить защиту приложения в фоне. Значение 0 отключает функцию. Чем меньше значение, тем выше возможный расход батареи. + Heartbeat отключён + Минимум — 5 с; будет исправлено при сохранении + Выполняется каждые %1$d с + + Сессия контейнера приостановлена + Выполняется сессия контейнера + Загрузка и установка компонентов + Чат Steam работает в фоновом режиме + : служба активна + : службы активны + и diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 030633a8a..68af9a13a 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1562,4 +1562,48 @@ Installerad sökväg: Det gick inte att skapa mappen Uppdatera + + + Logg över avslutsorsak + Registrerar varför appens process senast avslutades + Kraschlogg + Spara native krasch-spår (tombstone) + Händelseövervakningslogg + Fångar en fokuserad systemlogg kring paus/återuppta-händelser + Loggtaggfilter + taggar valda + Filtrera loggrader efter text… + Läge + Alla + Inkludera + Exkludera + Lägg till egen tagg… + Inga taggar valda + + Bakgrundsskydd + Håll sessionen vid liv i bakgrunden + Pausläge i bakgrunden + Pausa alla processer + Suspenderar alla processer — maximal batteribesparing (kan orsaka problem). + Pausa endast spelet + Suspenderar endast den aktiva spelprocessen; övriga processer och tjänster fortsätter köras. + Pausa allt utom spelet + Suspenderar alla processer och tjänster men håller spelprocessen aktiv. + Pausa sessionen automatiskt + Pausar sessionen automatiskt när appen hamnar i bakgrunden eller enheten låses. + Aktivera ett CPU-wakelock för att stärka bakgrundsskyddet + Hindrar processorn från djupsömn medan sessionen är pausad. Ökar batteriförbrukningen men kan förbättra persistensen och stabiliteten på enheter med aggressiv batterihantering. + Heartbeat-frekvens (sekunder) + Kan förbättra skyddet av appen i bakgrunden. Värdet 0 inaktiverar funktionen. Ju lägre värde, desto högre potentiell batteriförbrukning. + Heartbeat inaktiverat + Minimum är 5 s — justeras vid sparande + Körs var %1$d:e sekund + + Behållarsessionen är pausad + En behållarsession körs + Laddar ner och installerar komponenter + Steam-chatt körs i bakgrunden + : tjänst aktiv + : tjänster aktiva + och diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index ad4dbef72..921e54ac7 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1562,4 +1562,48 @@ สร้างโฟลเดอร์ล้มเหลว รีเฟรช + + + บันทึกสาเหตุการปิด + บันทึกว่าทำไมโปรเซสของแอปจึงถูกปิดครั้งล่าสุด + บันทึกการแครช + บันทึกร่องรอย tombstone ของการแครชแบบเนทีฟ + บันทึกเฝ้าดูเหตุการณ์ + เก็บบันทึกระบบเฉพาะช่วงเหตุการณ์หยุดชั่วคราว/ทำต่อ + ตัวกรองแท็กบันทึก + แท็กที่เลือก + กรองบรรทัดบันทึกตามข้อความ… + โหมด + ทั้งหมด + รวม + ยกเว้น + เพิ่มแท็กกำหนดเอง… + ไม่ได้เลือกแท็ก + + การป้องกันเบื้องหลัง + คงเซสชันให้ทำงานเมื่ออยู่เบื้องหลัง + โหมดหยุดชั่วคราวเบื้องหลัง + หยุดทุกโปรเซสชั่วคราว + ระงับทุกโปรเซส — ประหยัดแบตเตอรี่สูงสุด (อาจทำให้เกิดปัญหา) + หยุดเฉพาะเกมชั่วคราว + ระงับเฉพาะโปรเซสของเกมที่ทำงานอยู่ ส่วนโปรเซสและบริการอื่นยังทำงานต่อ + หยุดทุกอย่างชั่วคราวยกเว้นเกม + ระงับทุกโปรเซสและบริการ แต่ยังให้โปรเซสของเกมทำงานต่อ + หยุดเซสชันชั่วคราวอัตโนมัติ + หยุดเซสชันชั่วคราวโดยอัตโนมัติเมื่อแอปอยู่เบื้องหลังหรืออุปกรณ์ถูกล็อก + เปิดใช้ CPU wake lock เพื่อเสริมการป้องกันเบื้องหลัง + ป้องกันไม่ให้ CPU เข้าสู่โหมดหลับลึกขณะเซสชันถูกหยุดชั่วคราว เพิ่มการใช้แบตเตอรี่ แต่อาจช่วยเพิ่มความต่อเนื่องและความเสถียรบนอุปกรณ์ที่จัดการแบตเตอรี่แบบเข้มงวด + ความถี่ heartbeat (วินาที) + อาจช่วยเพิ่มการป้องกันแอปเบื้องหลัง ค่า 0 จะปิดฟีเจอร์นี้ ยิ่งค่าต่ำ ยิ่งอาจใช้แบตเตอรี่มากขึ้น + ปิด heartbeat แล้ว + ค่าต่ำสุดคือ 5 วินาที — จะถูกปรับเมื่อบันทึก + ทำงานทุก %1$d วินาที + + เซสชันคอนเทนเนอร์ถูกหยุดชั่วคราว + มีเซสชันคอนเทนเนอร์กำลังทำงาน + กำลังดาวน์โหลดและติดตั้งส่วนประกอบ + แชท Steam กำลังทำงานเบื้องหลัง + : บริการกำลังทำงาน + : บริการกำลังทำงาน + และ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 53699ce6c..b144bebe3 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1562,4 +1562,48 @@ Yüklü konum: Klasör oluşturma başarısız oldu Yenile + + + Çıkış nedeni günlüğü + Uygulama işleminin son kapanma nedenini kaydeder + Çökme günlüğü + Yerel çökme (tombstone) izlerini kaydet + Olay izleme günlüğü + Duraklatma/devam etme olayları çevresinde odaklanmış bir sistem günlüğü yakalar + Günlük etiketi filtresi + etiket seçildi + Günlük satırlarını metne göre filtrele… + Mod + Tümü + Dahil et + Hariç tut + Özel etiket ekle… + Etiket seçilmedi + + Arka Plan Koruması + Arka plandayken oturumu canlı tut + Arka plan duraklatma modu + Tüm işlemleri duraklat + Tüm işlemleri askıya alır — maksimum pil tasarrufu (sorunlara yol açabilir). + Yalnızca oyunu duraklat + Yalnızca etkin oyun işlemini askıya alır; diğer işlemler ve hizmetler çalışmaya devam eder. + Oyun dışında her şeyi duraklat + Tüm işlemleri ve hizmetleri askıya alır, ancak oyun işlemini etkin tutar. + Oturumu otomatik duraklat + Uygulama arka plana geçtiğinde veya cihaz kilitlendiğinde oturumu otomatik olarak duraklatır. + Arka plan korumasını güçlendirmek için CPU uyanık kilidini etkinleştir + Oturum duraklatılmışken CPU\'nun derin uykuya geçmesini önler. Pil kullanımını artırır, ancak agresif pil yönetimi olan cihazlarda kalıcılığı ve kararlılığı iyileştirebilir. + Kalp atışı sıklığı (saniye) + Arka planda uygulama korumasını iyileştirebilir. 0 değeri özelliği devre dışı bırakır. Değer ne kadar düşükse olası pil tüketimi o kadar yüksek olur. + Kalp atışı devre dışı + En düşük değer 5 sn — kaydederken düzeltilecek + Her %1$d saniyede bir çalışır + + Konteyner oturumu duraklatıldı + Çalışan bir konteyner oturumu var + Bileşenler indiriliyor ve kuruluyor + Steam sohbeti arka planda çalışıyor + : hizmet etkin + : hizmetler etkin + ve diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 701a1fc3d..2bb68296a 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1571,5 +1571,49 @@ Працювати у фоні Залишати чат активним після виходу з WinNative Оновити + + + Журнал причин завершення + Записувати причину останнього завершення процесу застосунку + Журнал збоїв + Зберігати трасування нативних збоїв (tombstone) + Журнал спостереження за подіями + Записувати системний журнал навколо подій паузи/відновлення + Фільтр тегів журналу + тегів вибрано + Фільтрувати рядки журналу за текстом… + Режим + Усі + Включати + Виключати + Додати власний тег… + Теги не вибрано + + Захист у фоновому режимі + Зберігати сесію активною у фоновому режимі + Режим паузи у фоні + Призупиняти всі процеси + Призупиняє всі процеси — максимальна економія батареї (можливі проблеми). + Призупиняти лише гру + Призупиняє лише процес активної гри; інші процеси та служби продовжують працювати. + Призупиняти все, крім гри + Призупиняє всі процеси та служби, але залишає процес гри активним. + Автопауза сесії + Автоматично призупиняти сесію, коли застосунок переходить у фон або пристрій заблоковано. + Увімкнути wake lock ЦП для посилення фонового захисту + Не дає ЦП переходити в глибокий сон, поки сесія на паузі. Збільшує витрату батареї, але може покращити персистентність і стабільність на пристроях з агресивною оптимізацією батареї. + Частота heartbeat (секунди) + Може покращити фоновий захист застосунку. Значення 0 вимикає функцію. Що менше значення, то більша можлива витрата батареї. + Heartbeat вимкнено + Мінімум — 5 с; буде виправлено під час збереження + Виконується кожні %1$d с + + Сесію контейнера призупинено + Виконується сесія контейнера + Завантаження та встановлення компонентів + Чат Steam працює у фоновому режимі + : служба активна + : служби активні + і diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 84c703039..0a8cf2f64 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1565,5 +1565,49 @@ 在后台运行 退出 WinNative 后保持聊天运行 刷新 + + + 退出原因日志 + 记录应用进程上次终止的原因 + 崩溃日志 + 保存原生崩溃 tombstone 跟踪 + 事件监视日志 + 记录暂停/恢复事件前后的系统日志 + 日志标签过滤器 + 个标签已选择 + 按文本过滤日志行… + 模式 + 全部 + 包含 + 排除 + 添加自定义标签… + 未选择标签 + + 后台保护 + 在后台保持会话运行 + 后台暂停模式 + 暂停所有进程 + 挂起所有进程 — 最大程度省电(可能导致问题)。 + 仅暂停游戏 + 仅挂起当前游戏进程;其他进程和服务继续运行。 + 暂停除游戏外的所有进程 + 挂起所有进程和服务,但保持游戏进程运行。 + 自动暂停会话 + 当应用进入后台或设备锁定时自动暂停会话。 + 启用 CPU 唤醒锁以增强后台保护 + 在会话暂停期间阻止 CPU 进入深度睡眠。会增加电量消耗,但可能提高在激进电池管理设备上的持续性和稳定性。 + 心跳频率(秒) + 可以改善应用的后台保护。0 表示禁用此功能。值越小,潜在耗电越高。 + 心跳已禁用 + 最小值为 5 秒 — 保存时将自动修正 + 每 %1$d 秒运行一次 + + 容器会话已暂停 + 有一个容器会话正在运行 + 正在下载并安装组件 + Steam 聊天正在后台运行 + :服务运行中 + :服务运行中 + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0102b4c29..2d7a7bea7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1564,5 +1564,49 @@ 在背景執行 離開 WinNative 後保持聊天執行 重新整理 + + + 結束原因日誌 + 記錄應用程式程序上次終止的原因 + 當機日誌 + 儲存原生當機 tombstone 追蹤 + 事件監視日誌 + 記錄暫停/繼續事件前後的系統日誌 + 日誌標籤篩選器 + 個標籤已選取 + 依文字篩選日誌行… + 模式 + 全部 + 包含 + 排除 + 新增自訂標籤… + 未選取標籤 + + 背景保護 + 在背景保持工作階段運作 + 背景暫停模式 + 暫停所有程序 + 暫停所有程序 — 最大程度省電(可能導致問題)。 + 僅暫停遊戲 + 僅暫停目前的遊戲程序;其他程序和服務繼續運作。 + 暫停除遊戲外的所有程序 + 暫停所有程序和服務,但保持遊戲程序運作。 + 自動暫停工作階段 + 當應用程式進入背景或裝置鎖定時,自動暫停工作階段。 + 啟用 CPU 喚醒鎖定以增強背景保護 + 在工作階段暫停期間防止 CPU 進入深度睡眠。會增加電池消耗,但可能提升在電池管理激進的裝置上的持續性與穩定性。 + 心跳頻率(秒) + 可以改善應用程式的背景保護。0 表示停用此功能。數值越小,潛在耗電越高。 + 心跳已停用 + 最小值為 5 秒 — 儲存時將自動修正 + 每 %1$d 秒執行一次 + + 容器工作階段已暫停 + 有一個容器工作階段正在執行 + 正在下載並安裝元件 + Steam 聊天正在背景執行 + :服務執行中 + :服務執行中 + From 32e80f5ff6b78fa134c90b9abfe31cea5f36d485 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Tue, 14 Jul 2026 21:13:58 +0200 Subject: [PATCH 35/35] =?UTF-8?q?Some=20more=20bugs=20fixed=20#1.=20-=20Mo?= =?UTF-8?q?ved=20the=20Timber=20and=20LogManager=20initializations=20from?= =?UTF-8?q?=20UnifiedActivity=20to=20PluviaApp=20for=20broader=20app-wide?= =?UTF-8?q?=20coverage.=20-=20Fixed=20a=20logic=20error=20when=20deciding?= =?UTF-8?q?=20whether=20to=20show=20the=20Exit=20button=20in=20the=20notif?= =?UTF-8?q?ication.=20=20=20-=20Renamed=20the=20isAppVisible()=20method=20?= =?UTF-8?q?to=20isAppNotVisible()=20to=20match=20the=20method=E2=80=99s=20?= =?UTF-8?q?own=20logic.=20-=20Fixed=20a=20potential=20bug=20that=20could?= =?UTF-8?q?=20occur=20when=20stopping=20the=20active=20service=20and=20sta?= =?UTF-8?q?rting=20a=20new=20component=20while=20the=20service=20is=20stil?= =?UTF-8?q?l=20stopping.=20-=20Fixed=20a=20bug=20that=20breaks=20PiP=20mod?= =?UTF-8?q?e.=20Wine=20processes=20getting=20paused=20in=20PiP.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/app/PluviaApp.kt | 12 +++++++ app/src/main/app/shell/UnifiedActivity.kt | 12 ------- .../main/feature/setup/SetupWizardActivity.kt | 8 +++-- .../stores/steam/service/SteamService.kt | 5 ++- .../display/XServerDisplayActivity.java | 4 +-- .../system/SessionKeepAliveService.java | 33 +++++++++++-------- 6 files changed, 41 insertions(+), 33 deletions(-) diff --git a/app/src/main/app/PluviaApp.kt b/app/src/main/app/PluviaApp.kt index 113418655..05a2d4657 100644 --- a/app/src/main/app/PluviaApp.kt +++ b/app/src/main/app/PluviaApp.kt @@ -11,6 +11,7 @@ import com.winlator.cmod.feature.stores.steam.events.EventDispatcher import com.winlator.cmod.feature.stores.steam.service.SteamService import com.winlator.cmod.feature.stores.steam.utils.PrefManager import com.winlator.cmod.runtime.display.XServerDisplayActivity +import com.winlator.cmod.runtime.system.LogManager import com.winlator.cmod.shared.android.RefreshRateUtils import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope @@ -18,6 +19,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber import java.io.File @HiltAndroidApp @@ -40,6 +42,16 @@ class PluviaApp : Application() { .init(this) scheduleColdStartWarmups() + // 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.applicationContext) + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Log.e("PluviaApp", "CRASH in thread ${thread.name}", throwable) } diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 69ecfd0b5..eb6e24bdc 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -237,14 +237,12 @@ 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 @@ -1514,16 +1512,6 @@ class UnifiedActivity : 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/setup/SetupWizardActivity.kt b/app/src/main/feature/setup/SetupWizardActivity.kt index 916d1c07d..e46193eef 100644 --- a/app/src/main/feature/setup/SetupWizardActivity.kt +++ b/app/src/main/feature/setup/SetupWizardActivity.kt @@ -895,9 +895,11 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { storageGranted.value = hasStoragePermission() notifGranted.value = hasNotificationPermissionSilently() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // Enable background protection by default on Android 14+. - androidx.preference.PreferenceManager.getDefaultSharedPreferences(this) - .edit { putBoolean("enable_background_session", true) } - Timber.d("Android 14+ detected") + if (!androidx.preference.PreferenceManager.getDefaultSharedPreferences(this).contains("enable_background_session")) { + androidx.preference.PreferenceManager.getDefaultSharedPreferences(this) + .edit { putBoolean("enable_background_session", true) } + Timber.d("Android 14+ detected, background session protection enabled") + } } backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", false) if (Build.VERSION.SDK_INT >= 36) { diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index c04f925d3..936f6cbe7 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -7862,7 +7862,7 @@ class SteamService : Service() { picsGetProductInfoJob?.cancel() messagePollerJob?.cancel() wnSession?.let { s -> runCatching { s.disconnect() } } - // ToDo: Check this Steam Friends code: + // ToDo: Check this Steam Friends code, is still needed?: /*scope.launch(Dispatchers.Main) { runCatching { stopForeground(STOP_FOREGROUND_REMOVE) } .onFailure { Timber.w(it, "Failed to remove SteamService foreground state on background suspend") } @@ -7870,6 +7870,9 @@ class SteamService : Service() { .onFailure { Timber.w(it, "Failed to cancel SteamService notification on background suspend") } }*/ // ToDo end. + // Commented out because Steam auto-start again in background after a few seconds. + /*runCatching { SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM) } + .onFailure { Timber.w(it, "Failed to remove SteamService foreground state during background") }*/ return true } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 1475ff1bf..41b9dfd0d 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -2436,11 +2436,9 @@ public void onPause() { boolean cleaningUp = exitRequested.get() || sessionCleanupStarted.get() || activityDestroyed.get(); - if (!cleaningUp) { + if (!cleaningUp && !isInPictureInPictureMode()) { if (autoPauseContainer) ProcessHelper.pauseAllWineProcesses(); - } - if (!cleaningUp && !isInPictureInPictureMode()) { if (environment != null) { environment.onPause(); xServerView.onPause(); diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index b9dbfa998..23bb8c883 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -72,6 +72,7 @@ public class SessionKeepAliveService extends Service { private static final AtomicBoolean sessionActive = new AtomicBoolean(false); private static final HashSet activeDownloads = new HashSet<>(); private static final AtomicBoolean serviceRunning = new AtomicBoolean(false); + private static volatile boolean serviceStopping = false; private static volatile XEnvironment activeEnvironment; private static volatile XServer activeXServer; @@ -238,7 +239,7 @@ public static void stopComponent(Context ctx, String componentName) { public static boolean isAppInBackground() { return isAppInBackground; } public static boolean isDeviceLocked() { return isScreenLocked; } - public static boolean isAppVisible() { + public static boolean isAppNotVisible() { return isAppInBackground || isScreenLocked; } @@ -274,7 +275,7 @@ public static void stopDownload(Context ctx, String tag) { private static boolean hasReason() { return sessionActive.get() || !activeDownloads.isEmpty() || !activeComponents.isEmpty() || - (isAppVisible() && PrefManager.INSTANCE.getChatStayRunningOnExit()); + (isAppNotVisible() && PrefManager.INSTANCE.getChatStayRunningOnExit()); } // Single chokepoint for every caller (session, components, downloads). @@ -285,7 +286,7 @@ private static synchronized void updateForegroundState(Context ctx) { SessionKeepAliveService svc = instance; if (hasReason()) { - if (svc != null) { + if (svc != null && !serviceStopping) { svc.ensureForeground(); } else { Context app = ctx.getApplicationContext(); @@ -299,6 +300,8 @@ private static synchronized void updateForegroundState(Context ctx) { } } else if (svc != null) { LogManager.log(TAG, "No active reason remains; stopping keep-alive service", ctx); + serviceStopping = true; + serviceRunning.set(false); svc.stopForegroundCompat(); svc.stopSelf(); } @@ -369,6 +372,12 @@ public void onReceive(Context context, Intent intent) { public int onStartCommand(Intent intent, int flags, int startId) { String action = intent != null ? intent.getAction() : null; + // 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); + // Handle the Exit button from the notification if (ACTION_SESSION_STOP.equals(action)) { exitingFromNotification = true; @@ -387,13 +396,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { 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. - if (hasReason()) { - ensureForeground(); - serviceRunning.set(true); - } else { + if (!hasReason()) { Timber.tag(TAG).d("onStartCommand found no active reason; stopping immediately"); stopForegroundCompat(); stopSelf(); @@ -405,8 +408,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()); // Disabled because container "Exit" causes too much issues, ANR crash for example. - boolean showExit = isAppVisible() && PrefManager.INSTANCE.getChatStayRunningOnExit(); +// boolean showExit = !isAppVisible() && (containerActive || PrefManager.INSTANCE.getChatStayRunningOnExit()); // Disabled because container "Exit" causes too much issues, ANR crash for example. + boolean showExit = isAppNotVisible() && (!containerActive && PrefManager.INSTANCE.getChatStayRunningOnExit()); // Determine target activity: Game screen if active, else Main menu Class targetActivity = containerActive ? XServerDisplayActivity.class : UnifiedActivity.class; @@ -481,6 +484,7 @@ public void onTaskRemoved(Intent rootIntent) { @Override public void onDestroy() { + serviceStopping = false; if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); // if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); stopHeartbeat(); @@ -495,6 +499,7 @@ public void onDestroy() { screenStateReceiver = null; } + notificationHelper.cancel(notificationId); serviceRunning.set(false); instance = null; super.onDestroy(); @@ -590,7 +595,7 @@ private static String getNotificationContent() { } // 3. MEDIUM PRIORITY: Steam friends (if enabled) - if (PrefManager.INSTANCE.getChatStayRunningOnExit() && isAppVisible()) + if (PrefManager.INSTANCE.getChatStayRunningOnExit() && isAppNotVisible()) return instance.getString(R.string.fg_keep_alive_notification_content_steam_chat_running); // 4. LOW PRIORITY: Active store services @@ -682,11 +687,11 @@ private static void performDefensiveCleanupAndExit(Context ctx) { } new Handler(Looper.getMainLooper()).post(() -> { + serviceRunning.set(false); 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);