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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] =?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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] =?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); From e92859339e705e7a050db479a0aad59d70aed095 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Wed, 15 Jul 2026 18:49:34 +0200 Subject: [PATCH 36/62] Some bug fixes and improvements (2). - Added a handler/timer to Event Watch log so it cannot stay running for more than 1.5 hour. - Auto-disable the log setting if the timeout is reached. - Useful in case someone forget to disable the log. - Fixed a bug with the exitReasons and crash preferences keys that could lead to miss every crash key and make the crash log to grow indefinitely. - Heartbeat: Changed the hardcoded 120s default value to 0 to match the default value in settings. - Also, added a return condition if wakeLock is disabled. - Fixed a potential ANR by moving the blocking cleanup (cleanUpSession method call on Exit button) to a background thread. - Fixed custom tag list in UI showing hardcoded tags too. - Wrapped the Epic and GOG service startup (startService call) in a try/catch, and extracted a method to avoid code duplication. - Fixed a bug in Filter Tag Window that hides half of the text in the Add custom tag Input Field. --- .../feature/settings/debug/DebugFragment.kt | 2 +- .../feature/settings/debug/DebugScreen.kt | 85 ++++++++++++++----- .../stores/epic/service/EpicService.kt | 17 ++-- .../feature/stores/gog/service/GOGService.kt | 17 ++-- .../display/XServerDisplayActivity.java | 2 - app/src/main/runtime/system/LogManager.kt | 37 +++++++- .../system/SessionKeepAliveService.java | 23 +++-- 7 files changed, 140 insertions(+), 43 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index 0e8e3f1e9..213c283ca 100644 --- a/app/src/main/feature/settings/debug/DebugFragment.kt +++ b/app/src/main/feature/settings/debug/DebugFragment.kt @@ -246,7 +246,7 @@ class DebugFragment : Fragment() { eventWatchLog = preferences.getBoolean("enable_event_watch_log", false), tagFilterMode = LogManager.getTagFilterMode(), selectedTags = LogManager.getSelectedTags().toList(), - customTags = LogManager.getAllKnownTags().filterNot { it in GeneratedLogTags.TAGS }, + customTags = LogManager.getCachedCustomTags(), wineDebug = preferences.getBoolean("enable_wine_debug", false), wineChannels = channels, diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index cfcd18697..6a7e8fc2a 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -136,10 +136,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import androidx.compose.material3.OutlinedTextField import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues 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.focus.onFocusChanged import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction @@ -374,7 +376,7 @@ fun DebugScreen( modifier = Modifier .fillMaxWidth() .padding(top = 8.dp) - .focusProperties { canFocus = state.appDebug || state.eventWatchLog }, + .focusProperties { canFocus = state.appDebug || state.eventWatchLog }, ) } @@ -527,7 +529,8 @@ private fun SettingsToggleCard( onActivate = { onCheckedChange(!checked) }, highlightColor = NavHighlight, tapToSelect = true, - ).padding(horizontal = 14.dp, vertical = 11.dp), + ) + .padding(horizontal = 14.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( @@ -554,7 +557,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, @@ -990,7 +995,8 @@ private fun SmallActionButton( }, onTap = { onClick() }, ) - }.padding(horizontal = 11.dp, vertical = 6.dp), + } + .padding(horizontal = 11.dp, vertical = 6.dp), contentAlignment = Alignment.Center, ) { Text( @@ -1270,18 +1276,45 @@ private fun LogTagFilterDialog( ) Spacer(Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( + // Simple state to track focus for the border color + var isTextFieldFocused by remember { mutableStateOf(false) } + + // Use BasicTextField for full control over vertical padding + androidx.compose.foundation.text.BasicTextField( 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 - ) + textStyle = androidx.compose.ui.text.TextStyle( + color = TextPrimary, + fontSize = 13.sp + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(Accent), + modifier = Modifier + .weight(1f) + .height(38.dp) + .onFocusChanged { isTextFieldFocused = it.isFocused }, + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .background(Color.Transparent, RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = if (isTextFieldFocused) Accent else CardBorder, + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.CenterStart + ) { + if (newTagText.isEmpty()) { + Text( + text = stringResource(R.string.settings_debug_add_custom_tag_hint), + fontSize = 12.sp, + color = TextSecondary + ) + } + innerTextField() + } + } ) Spacer(Modifier.width(8.dp)) SmallActionButton( @@ -1515,7 +1548,11 @@ private fun RowScope.LogActionButton( .clip(RoundedCornerShape(12.dp)) .background(CardDark) .border(1.dp, accentColor.copy(alpha = 0.22f), RoundedCornerShape(12.dp)) - .paneNavItem(cornerRadius = 12.dp, onActivate = { onClick() }, highlightColor = NavHighlight) + .paneNavItem( + cornerRadius = 12.dp, + onActivate = { onClick() }, + highlightColor = NavHighlight + ) .pointerInput(onClick) { detectTapGestures( onPress = { @@ -1525,7 +1562,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, ) { @@ -1732,7 +1770,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( @@ -1777,7 +1816,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( @@ -1822,7 +1862,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( @@ -1973,7 +2014,8 @@ private fun LogFileRow( .paneNavItem(cornerRadius = 8.dp, onActivate = { onOpen() }) .pointerInput(entry.absolutePath) { detectTapGestures(onTap = { onOpen() }) - }.padding(horizontal = 4.dp, vertical = 2.dp), + } + .padding(horizontal = 4.dp, vertical = 2.dp), ) { Text( text = entry.name, @@ -2218,7 +2260,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/feature/stores/epic/service/EpicService.kt b/app/src/main/feature/stores/epic/service/EpicService.kt index 2e83e5d74..1afe6a8cc 100644 --- a/app/src/main/feature/stores/epic/service/EpicService.kt +++ b/app/src/main/feature/stores/epic/service/EpicService.kt @@ -94,8 +94,7 @@ class EpicService : Service() { val intent = Intent(context, EpicService::class.java) intent.action = ACTION_SYNC_LIBRARY - // Just start as a normal service. KeepAliveService should protect this. - context.startService(intent) + startEpicService(context, intent) return } @@ -111,15 +110,23 @@ class EpicService : Service() { Timber.tag("EPIC").i("Starting service without sync - throttled (${remainingMinutes}min remaining)") } - // Just start as a normal service. KeepAliveService should protect this. - context.startService(intent) + startEpicService(context, 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.startService(intent) + startEpicService(context, intent) + } + + fun startEpicService(context: Context, intent: Intent) { + try { + // Just start as a normal service. KeepAliveService should protect this. + context.startService(intent) + } catch (e: Exception) { + Timber.e(e, "Failed to start EpicService") + } } fun stop() { diff --git a/app/src/main/feature/stores/gog/service/GOGService.kt b/app/src/main/feature/stores/gog/service/GOGService.kt index 34db209a5..813eb5561 100644 --- a/app/src/main/feature/stores/gog/service/GOGService.kt +++ b/app/src/main/feature/stores/gog/service/GOGService.kt @@ -99,8 +99,7 @@ class GOGService : Service() { val intent = Intent(context, GOGService::class.java) intent.action = ACTION_SYNC_LIBRARY - // Just start as a normal service. KeepAliveService should protect this. - context.startService(intent) + startGOGService(context, intent) return } @@ -116,15 +115,23 @@ class GOGService : Service() { Timber.d("[GOGService] Starting service without sync - throttled (${remainingMinutes}min remaining)") } - // Just start as a normal service. KeepAliveService should protect this. - context.startService(intent) + startGOGService(context, 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.startService(intent) + startGOGService(context, intent) + } + + fun startGOGService(context: Context, intent: Intent) { + try { + // Just start as a normal service. KeepAliveService should protect this. + context.startService(intent) + } catch (e: Exception) { + Timber.e(e, "Failed to start GOGService") + } } fun stop() { diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 41b9dfd0d..39c01692c 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -5504,10 +5504,8 @@ private boolean handleDrawerAction(int itemId) { case R.id.main_menu_pause: if (isPaused) { ProcessHelper.resumeAllWineProcesses(); - SessionKeepAliveService.onResumeSession(this); } else { - SessionKeepAliveService.onPauseSession(this); ProcessHelper.pauseAllWineProcesses(); if (touchpadView != null) touchpadView.resetInputState(); if (inputControlsView != null) inputControlsView.cancelActiveTouches(); diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 63a66b9fe..c45ca1aa4 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -7,6 +7,8 @@ import android.content.SharedPreferences import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.ActivityCompat @@ -64,6 +66,22 @@ object LogManager { "XServerLeakCheck", ) + private const val EVENT_WATCH_TIMEOUT_MS = 90 * 60 * 1000L // 1.5 hours + private val watchTimeoutHandler = Handler(Looper.getMainLooper()) + private val stopWatchTask = Runnable { + Timber.i("Event watch timeout reached. Stopping.") + stopEventWatch() + + // Auto-disable the toggle in preferences/UI just in case the user may have forgotten + // that the logger was enabled, to avoid wasting battery life. + appContext?.let { context -> + PreferenceManager.getDefaultSharedPreferences(context).edit { + putBoolean(PREF_ENABLE_EVENT_WATCH_LOG, false) + } + } + } + + 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" @@ -262,6 +280,9 @@ object LogManager { .distinct() .sorted() + @JvmStatic + fun getCachedCustomTags(): List = cachedCustomTags.sorted() + @JvmStatic fun addCustomTag(context: Context, tag: String) { val cleaned = tag.trim() @@ -563,8 +584,10 @@ object LogManager { } eventWatchProcess = Runtime.getRuntime().exec(command.toTypedArray()) - closeProcessStdin(eventWatchProcess) + + // Schedule a timeout to avoid hanging indefinitely if the user forget to disable this log. + watchTimeoutHandler.postDelayed(stopWatchTask, EVENT_WATCH_TIMEOUT_MS) } catch (e: Exception) { logE(TAG, null, context) { "Failed to start event watch: ${e.message}" } } @@ -572,6 +595,9 @@ object LogManager { @JvmStatic fun stopEventWatch() { + // Cancel the pending timeout task + watchTimeoutHandler.removeCallbacks(stopWatchTask) + try { eventWatchProcess?.let(::destroyProcess) eventWatchProcess = null @@ -848,7 +874,14 @@ object LogManager { private fun persistKeys(prefs: SharedPreferences, prefKey: String, keys: Set) { prefs.edit { - putString(prefKey, keys.sortedDescending().take(20).joinToString(",")) + // Sort by the numeric timestamp (the last part of the key) instead of alphabetically. + // This ensures we keep the 20 most recent events regardless of whether they + // are "exit_" or "crash_". + val sortedKeys = keys.sortedByDescending { key -> + key.substringAfterLast('_').toLongOrNull() ?: 0L + }.take(20) + + putString(prefKey, sortedKeys.joinToString(",")) } } diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 23bb8c883..cf294cec4 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -104,7 +104,6 @@ public class SessionKeepAliveService extends Service { 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<>(); @@ -117,7 +116,7 @@ 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); + int frequency = prefs.getInt(PREF_HEARTBEAT_FREQUENCY, 0); if (frequency > 0) { if (frequency < 5) heartbeat_interval_ms = 5 * 1000L; @@ -128,7 +127,6 @@ public static void startSession(Context ctx) { sessionActive.set(true); isContainerPaused = false; - isActivityVisible = true; exitingFromNotification = false; LogManager.log(TAG, "startSession", ctx); updateForegroundState(ctx); @@ -141,7 +139,6 @@ public static void onPauseSession(Context ctx) { return; } isContainerPaused = true; - isActivityVisible = false; LogManager.log(TAG, "onPauseSession", ctx); if (instance != null) { instance.acquireWakeLock(); @@ -158,7 +155,6 @@ public static void onResumeSession(Context ctx) { return; } isContainerPaused = false; - isActivityVisible = true; LogManager.log(TAG, "onResumeSession", ctx); if (instance != null) { instance.stopHeartbeat(); @@ -388,7 +384,19 @@ public int onStartCommand(Intent intent, int flags, int startId) { // and it will update the notification to the "Chat" state. if (sessionActive.get() && chatStayAlive) { stopSession(this); - cleanUpSession(this, "Exit button pressed"); + + // Move the blocking cleanup to a background thread to prevent ANRs. + // We use a small delay to allow stopSession's environment teardown to start. + new Thread(() -> { + try { + Thread.sleep(1000L); + cleanUpSession(getApplicationContext(), "Exit button pressed"); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } catch (Throwable t) { + LogManager.logW(TAG, "Background session cleanup failed", t, getApplicationContext()); + } + }, "SessionExitCleanup").start(); } else { // Otherwise, perform a full app shutdown. closeApp(this); @@ -534,7 +542,7 @@ private void releaseWakeLock() { private void startHeartbeat() { if (prefs == null) return; - if (heartbeatRunning || prefs.getInt(PREF_HEARTBEAT_FREQUENCY, 0) <= 0) return; + if (heartbeatRunning || !prefs.getBoolean("enable_background_wakelock", false) || prefs.getInt(PREF_HEARTBEAT_FREQUENCY, 0) <= 0) return; heartbeatRunning = true; Thread t = new Thread(() -> { while (heartbeatRunning && sessionActive.get() && isContainerPaused) { @@ -664,7 +672,6 @@ public void onStop(@NonNull androidx.lifecycle.LifecycleOwner owner) { private static void resetLocalState() { sessionActive.set(false); isContainerPaused = false; - isActivityVisible = false; synchronized (activeDownloads) { activeDownloads.clear(); } activeComponents.clear(); } From 4c92334ad6f38ed4815ea0b938e5cc2c39cc7e44 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Wed, 15 Jul 2026 23:31:20 +0200 Subject: [PATCH 37/62] =?UTF-8?q?Some=20bug=20fixes=20and=20improvements?= =?UTF-8?q?=20(3).=20-=20Fixed=20a=20possible=20bug=20that=20could=20block?= =?UTF-8?q?=20the=20main=20thread=20when=20generating=20log=20files=20duri?= =?UTF-8?q?ng=20LogManager=20initialization=20or=20when=20calling=20startE?= =?UTF-8?q?ventWatch=20from=20the=20onPause()=20method=20of=20XServerDispl?= =?UTF-8?q?ayActivity.=20-=20Fixed=20a=20bug=20in=20`inline=20fun=20logW`?= =?UTF-8?q?=20and=20`inline=20fun=20logE`=20that=20were=20always=20passing?= =?UTF-8?q?=20null=20as=20the=20Throwable=20to=20`baseLog`,=20so=20they=20?= =?UTF-8?q?never=20actually=20used=20Throwable.=20-=20Refactor=20SessionKe?= =?UTF-8?q?epAliveService=20to=20avoid=20exceptions=20in=20some=20race=20c?= =?UTF-8?q?onditions.=20=20=20-=20`getNotificationContent()`=20>=20`names.?= =?UTF-8?q?get(0)`=20=E2=86=92=20`IndexOutOfBoundsException`.=20=20=20-=20?= =?UTF-8?q?Narrowed=20NPE=20windows=20moving=20`instance`=20initialization?= =?UTF-8?q?=20and=20nullification=20to=20a=20better=20position.=20-=20The?= =?UTF-8?q?=20code=20in=20XServerDisplayActivity=20>=20onCreate()=20that?= =?UTF-8?q?=20caused=20the=20container/game=20to=20render=20on=20Top=20of?= =?UTF-8?q?=20the=20lock=20screen=20has=20been=20fully=20commented=20out.?= =?UTF-8?q?=20It=E2=80=99s=20a=20nuisance=20if=20you=20want=20to=20do=20an?= =?UTF-8?q?ything=20else=20after=20unlocking=20the=20device.=20-=20Fixed?= =?UTF-8?q?=20a=20bug=20in=20English=20strings=20related=20to=20notificati?= =?UTF-8?q?on=20store=20message.=20-=20Fixed:=20`XConnectorEpoll.shutdown(?= =?UTF-8?q?)`=20busy-spins=20at=20100=20%=20CPU=20when=20it=20loses=20the?= =?UTF-8?q?=20`markKillingOnce()`=20race=20until=20the=20winner=20finishes?= =?UTF-8?q?=20teardown.=20-=20Fixed=20`stopEventWatch`=20timer=20never=20c?= =?UTF-8?q?anceled:=20pause=E2=86=92resume=E2=86=92pause=20within=208s=20k?= =?UTF-8?q?ills=20the=20fresh=20watch.=20-=20Replaced=20every=20Timber=20`?= =?UTF-8?q?e`=20and=20`w`=20log=20inside=20LogManager=20with=20logE/logW.?= =?UTF-8?q?=20=20=20-=20Fallback=20to=20android=20Log=20in=20baseLog=20whe?= =?UTF-8?q?n=20Timber=20is=20not=20configured=20and=20is=20an=20error=20or?= =?UTF-8?q?=20warning=20log.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/app/PluviaApp.kt | 2 +- app/src/main/res/values/strings.xml | 4 +- .../display/XServerDisplayActivity.java | 23 +++- .../display/connector/XConnectorEpoll.java | 42 +++++-- app/src/main/runtime/system/LogManager.kt | 108 +++++++++++------- .../system/SessionKeepAliveService.java | 31 +++-- 6 files changed, 139 insertions(+), 71 deletions(-) diff --git a/app/src/main/app/PluviaApp.kt b/app/src/main/app/PluviaApp.kt index 05a2d4657..ab56d645c 100644 --- a/app/src/main/app/PluviaApp.kt +++ b/app/src/main/app/PluviaApp.kt @@ -42,7 +42,7 @@ class PluviaApp : Application() { .init(this) scheduleColdStartWarmups() - // Initialize Timber in debug builds for logging. + // Initialize Timber in debug builds for app wide logging. // If 'BuildConfig' give error, ignore it. // The file needed will be autogenerated when building the apk. if (com.winlator.cmod.BuildConfig.DEBUG) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fca494479..6c371ade5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1603,7 +1603,7 @@ Installed path: There is a container session running Downloading and installing components Steam chat running in background - service is active - services are active + ": 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 39c01692c..f505944cc 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -329,6 +329,8 @@ public void run() { } }; + private final Runnable stopEventWatchTask = LogManager::stopEventWatch; + private void fireDrawerStickDir(int dir) { if (drawerStateHolder == null) return; if (!drawerStateHolder.isPaneOpen()) { @@ -919,8 +921,12 @@ public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); AppUtils.hideSystemUI(this); AppUtils.keepScreenOn(this); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) { -// setShowWhenLocked(true); + + // SHOW THE CONTAINER/GAME ON TOP OF LOCK SCREEN + // Completely disabled, because the purpose of this code was to prevent the container + // from being killed by the OS, but it didn’t work. And it became a nuisance. + /*if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true); setTurnScreenOn(true); } else { getWindow().addFlags( @@ -939,7 +945,8 @@ public void onCreate(Bundle savedInstanceState) { Log.w(TAG, "requestDismissKeyguard failed: " + t.getMessage()); } - } + }*/ + DebugFragment.Companion.cleanupSharedLogs(); com.winlator.cmod.runtime.system.LogManager.prepareForNewSession(this); @@ -2416,12 +2423,17 @@ public void onResume() { SessionKeepAliveService.onResumeSession(this); LogManager.log(TAG, "Session resumed", getApplicationContext()); - handler.postDelayed(LogManager::stopEventWatch, 8000); + // Cancel any pending stop task and re-schedule it + handler.removeCallbacks(stopEventWatchTask); + handler.postDelayed(stopEventWatchTask, 8000); } @Override public void onPause() { - LogManager.startEventWatch(getApplicationContext(), "onPause"); + // Cancel the scheduled stop immediately so it doesn't kill + // the watcher we are about to start below. + handler.removeCallbacks(stopEventWatchTask); + LogManager.startEventWatch(getApplicationContext(), "XServerDisplayActivity.onPause"); LogManager.log(TAG, "Session paused; entering background", getApplicationContext()); SessionKeepAliveService.onPauseSession(this); @@ -3112,6 +3124,7 @@ private void closeAfterSessionExit() { // XServerDisplayActivity and surface UnifiedActivity naturally, all // while the task stays behind whatever the user was doing. moveTaskToBack(true); + finish(); return; } diff --git a/app/src/main/runtime/display/connector/XConnectorEpoll.java b/app/src/main/runtime/display/connector/XConnectorEpoll.java index ba20751c3..f052f7ee9 100644 --- a/app/src/main/runtime/display/connector/XConnectorEpoll.java +++ b/app/src/main/runtime/display/connector/XConnectorEpoll.java @@ -147,33 +147,50 @@ public void killConnection(Client client, String reason) { return; } + // Winner of this check handles the teardown. + if (!client.markKillingOnce()) return; // already being/been torn down by another thread + int fd = client.clientSocket != null ? client.clientSocket.fd : -1; // 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 + // FIX: Remove from map FIRST. This fixes: + // 1. Busy-spinning in shutdown() if it lost the race. + // 2. FD-reuse race where a new connection on the same FD could be deleted by this cleanup. + synchronized (connectedClients) { + connectedClients.remove(fd); + } + client.connected = false; connectionHandler.handleConnectionShutdown(client); if (multithreadedClients) { - if (Thread.currentThread() != client.pollThread) { + if (client.pollThread != null && Thread.currentThread() != client.pollThread) { client.requestShutdown(); - while (client.pollThread.isAlive()) { - try { - client.pollThread.join(); - } catch (InterruptedException e) { + // FIX: Uninterruptible join. Proceeding after an InterruptedException + // would free buffers while the poll thread is potentially still alive. + boolean interrupted = Thread.interrupted(); + try { + while (client.pollThread.isAlive()) { + try { + client.pollThread.join(); + } catch (InterruptedException e) { + interrupted = true; + } + } + } finally { + if (interrupted) { // Restore interrupt status and log interruption Thread.currentThread().interrupt(); LogManager.logW(TAG, "Interrupted while joining pollThread for fd=" + fd + " reason=\"" + reason + "\""); - break; } } - client.pollThread = null; } closeFd(client.shutdownFd); - } else removeFdFromEpoll(epollFd, client.clientSocket.fd); + } else removeFdFromEpoll(epollFd, fd); + // Free direct byte buffers and ancillary FDs to avoid resource leak warnings try { client.releaseIOStreams(); @@ -202,8 +219,11 @@ private void shutdown() { while (true) { Client client; synchronized (connectedClients) { - if (connectedClients.size() == 0) break; - client = connectedClients.valueAt(connectedClients.size() - 1); + int size = connectedClients.size(); + if (size == 0) break; + // Pop from the map here to prevent busy-spinning if killConnection returns early. + client = connectedClients.valueAt(size - 1); + connectedClients.removeAt(size - 1); } killConnection(client, "connector shutdown"); } diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index c45ca1aa4..ec216f660 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -22,6 +22,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import androidx.core.content.edit +import com.winlator.cmod.BuildConfig object LogManager { private const val TAG = "LogManager" @@ -166,8 +167,12 @@ object LogManager { crashHandlerInitialized = true } - logLastExitReasons(app) // Capture previous exit reasons if toggle is enabled. - runPostMortemIfNeeded(app) // Capture unexpected crashes at start if crash toggle is disabled. + // Move heavy I/O processing (exit reasons, ANR traces, post-mortems) + // to a dedicated background thread to prevent ANRs in onCreate. + Thread({ + logLastExitReasons(app) + runPostMortemIfNeeded(app) // Capture previous exit reasons if toggle is enabled. + }, "LogManager-StartupIO").start() // Capture unexpected crashes at start if crash toggle is disabled. } private fun refreshCaches(context: Context) { @@ -230,7 +235,7 @@ object LogManager { 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}") + logW(TAG,e, ctx) { "Failed to resolve winlator_path_uri ($uriStr): ${e.message}" } uriStr } } @@ -363,7 +368,7 @@ object LogManager { ) closeProcessStdin(logcatProcess) } catch (e: Exception) { - Timber.tag(TAG).e("Failed to start logcat: ${e.message}") + logE(TAG,e, context) { "Failed to start logcat: ${e.message}" } } } @@ -377,7 +382,7 @@ object LogManager { logcatProcess?.let(::destroyProcess) logcatProcess = null } catch (e: Exception) { - Timber.tag(TAG).e("Failed to stop logcat: ${e.message}") + logE(TAG,e) { "Failed to stop logcat: ${e.message}" } } } @@ -402,7 +407,7 @@ object LogManager { closeProcessStdin(appLogProcess) Timber.i("Application debug logging started (PID=$pid)") } catch (e: Exception) { - Timber.e("Failed to start application logging: ${e.message}") + logE(TAG,e) { "Failed to start application logging: ${e.message}" } } } @@ -412,7 +417,7 @@ object LogManager { appLogProcess?.let(::destroyProcess) appLogProcess = null } catch (e: Exception) { - Timber.e("Failed to stop application logging: ${e.message}") + logE(TAG,e) { "Failed to stop application logging: ${e.message}" } } } @@ -511,21 +516,37 @@ object LogManager { inline fun logW(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) { if (!cachedAppDebugEnabled) return - baseLog(Level.WARN, tag, message(), null, context) + baseLog(Level.WARN, tag, message(), t, context) } inline fun logE(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) { if (!cachedAppDebugEnabled) return - baseLog(Level.ERROR, tag, message(), null, context) + baseLog(Level.ERROR, tag, message(), t, 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. + val hasTree = Timber.forest().isNotEmpty() + + // Mirrors Timber 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) + Level.WARN -> { + if (hasTree) { + if (t != null) Timber.tag(tag).w(t, message) else Timber.tag(tag).w(message) + } + else { + if (t != null) Log.w(tag, message, t) else Log.w(tag, message) + } + } + Level.ERROR -> { + if (hasTree) { + if (t != null) Timber.tag(tag).e(t, message) else Timber.tag(tag).e(message) + } + else { // Fallback to Android Log for when Timber is not available + if (t != null) Log.e(tag, message, t) else Log.e(tag, message) + } + } } if (!cachedAppDebugEnabled) return @@ -541,7 +562,8 @@ object LogManager { try { File(getLogsDir(context), fileName).appendText("${timestampFormat.format(Date())} $level: $message\n") } catch (e: Exception) { - Timber.tag(TAG).e("Failed to append to $fileName: ${e.message}") + // Need to be Log for when baseLog and Timber doesn't work. + Log.e(TAG, "Failed to append to $fileName: ${e.message}", e) } } @@ -566,31 +588,33 @@ object LogManager { if (!hasReadLogs) logW(TAG, null, context) { "READ_LOGS permission not granted, pause watch may not capture system logs" } 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("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) ===") - - val command = mutableListOf("logcat", "-v", "threadtime", "-f", file.absolutePath) - command.addAll(buildLogcatFilterSpecArgs()) - manualTextFilter?.let { - command.add("-e") - command.add(it) - } + Thread({ + 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("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) ===") + + val command = mutableListOf("logcat", "-v", "threadtime", "-f", file.absolutePath) + command.addAll(buildLogcatFilterSpecArgs()) + manualTextFilter?.let { + command.add("-e") + command.add(it) + } - eventWatchProcess = Runtime.getRuntime().exec(command.toTypedArray()) - closeProcessStdin(eventWatchProcess) + eventWatchProcess = Runtime.getRuntime().exec(command.toTypedArray()) + closeProcessStdin(eventWatchProcess) - // Schedule a timeout to avoid hanging indefinitely if the user forget to disable this log. - watchTimeoutHandler.postDelayed(stopWatchTask, EVENT_WATCH_TIMEOUT_MS) - } catch (e: Exception) { - logE(TAG, null, context) { "Failed to start event watch: ${e.message}" } - } + // Schedule a timeout to avoid hanging indefinitely if the user forget to disable this log. + watchTimeoutHandler.postDelayed(stopWatchTask, EVENT_WATCH_TIMEOUT_MS) + } catch (e: Exception) { + logE(TAG, e, context) { "Failed to start event watch: ${e.message}" } + } + }, "LogManager-EventWatch").start() } @JvmStatic @@ -602,7 +626,7 @@ object LogManager { eventWatchProcess?.let(::destroyProcess) eventWatchProcess = null } catch (e: Exception) { - Timber.e("Failed to stop pause watch: ${e.message}") + logE(TAG,e) { "Failed to stop pause watch: ${e.message}" } } } @@ -733,7 +757,7 @@ object LogManager { 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}") + logE(TAG,e, context) { "Failed to read historical trace: ${e.message}" } } } // Mark as crash-processed to avoid re-scanning non-crash reasons @@ -746,7 +770,7 @@ object LogManager { persistKeys(prefs, PREF_LOGGED_EXIT_KEYS, loggedKeys) } } catch (e: Exception) { - Timber.tag(TAG).e("Failed to read exit reasons: ${e.message}") + logE(TAG,e, context) { "Failed to read exit reasons: ${e.message}" } } } @@ -783,9 +807,9 @@ object LogManager { } file.writeText(crashInfo) - Timber.e(throwable, "Crash logged to $fileName") + logE(TAG, throwable) { "Crash logged to $fileName" } } catch (e: Exception) { - Timber.e(e, "Failed to log crash") + logE(TAG,e, context) { "Failed to log crash" } } } @@ -826,7 +850,7 @@ object LogManager { } if (wrote) persistKeys(prefs, PREF_POST_MORTEM_KEYS, loggedKeys) } catch (e: Exception) { - Timber.tag(TAG).e("Post-mortem check failed: ${e.message}") + logE(TAG,e, context) { "Post-mortem check failed: ${e.message}" } } } diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index cf294cec4..c4ae74c3c 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -310,7 +310,6 @@ private static synchronized void updateForegroundState(Context ctx) { @Override public void onCreate() { super.onCreate(); - instance = this; // Initialize the helper using the application context notificationHelper = new NotificationHelper(getApplicationContext()); @@ -327,6 +326,9 @@ public void onCreate() { wakeLock.setReferenceCounted(false); } + // Now that fields are ready, publish the instance + instance = this; + // Seed initial state from current lifecycle rather than assuming foreground. isAppInBackground = !androidx.lifecycle.ProcessLifecycleOwner.get() .getLifecycle().getCurrentState() @@ -416,7 +418,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, ANR crash for example. +// boolean showExit = isAppNotVisible() && (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 @@ -492,7 +494,11 @@ public void onTaskRemoved(Intent rootIntent) { @Override public void onDestroy() { + // Null the instance immediately to close the window for other threads + instance = null; serviceStopping = false; + serviceRunning.set(false); + if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); // if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); stopHeartbeat(); @@ -507,9 +513,9 @@ public void onDestroy() { screenStateReceiver = null; } - notificationHelper.cancel(notificationId); - serviceRunning.set(false); - instance = null; + if (notificationHelper != null) { + notificationHelper.cancel(notificationId); + } super.onDestroy(); } @@ -592,25 +598,30 @@ private void runOomSweepInternal() { @NonNull private static String getNotificationContent() { + // Capture a local reference to avoid NPE if instance is nulled concurrently + SessionKeepAliveService svc = instance; + if (svc == null) return "WinNative is running in the background"; + // 1. HIGHEST PRIORITY: The game/container if (sessionActive.get()) { - return isContainerPaused ? instance.getString(R.string.fg_keep_alive_notification_content_container_paused) : instance.getString(R.string.fg_keep_alive_notification_content_container_running); + return isContainerPaused ? svc.getString(R.string.fg_keep_alive_notification_content_container_paused) : svc.getString(R.string.fg_keep_alive_notification_content_container_running); } // 2. MEDIUM PRIORITY: Downloads synchronized (activeDownloads) { - if (!activeDownloads.isEmpty()) return instance.getString(R.string.fg_keep_alive_notification_content_downloading_installing); + if (!activeDownloads.isEmpty()) return svc.getString(R.string.fg_keep_alive_notification_content_downloading_installing); } // 3. MEDIUM PRIORITY: Steam friends (if enabled) if (PrefManager.INSTANCE.getChatStayRunningOnExit() && isAppNotVisible()) - return instance.getString(R.string.fg_keep_alive_notification_content_steam_chat_running); + return svc.getString(R.string.fg_keep_alive_notification_content_steam_chat_running); // 4. LOW PRIORITY: Active store services + // Fix: Copy keys first to avoid IndexOutOfBoundsException if map is cleared between check and access + List names = new ArrayList<>(activeComponents.keySet()); if (!activeComponents.isEmpty()) { // Define the priority order List priority = Arrays.asList(COMPONENT_STEAM, COMPONENT_EPIC, COMPONENT_GOG); - List names = new ArrayList<>(activeComponents.keySet()); names.sort((a, b) -> { int idxA = priority.indexOf(a); int idxB = priority.indexOf(b); @@ -646,7 +657,7 @@ private static String getNotificationContent() { joinedNames = sb.toString(); } - 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); + String suffix = (size == 1) ? svc.getString(R.string.fg_keep_alive_notification_content_store_service_active) : svc.getString(R.string.fg_keep_alive_notification_content_store_services_active); return joinedNames + suffix; } return "WinNative is running in the background"; From 85b5b45c818a1b26523c76f7cf0651d830306dba Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Thu, 16 Jul 2026 19:19:45 +0200 Subject: [PATCH 38/62] Some bug fixes and improvements (4). - Fixed `resolvePathString` in LogManager, which was silently falling back to an incorrect path and, in some cases, prevented log files from being generated. - Modernized date formatting in LogManager using thread-safe DateTimeFormatter to prevent race conditions and potential crashes when logging from multiple threads simultaneously. - Enabled case-insensitive regex support for the manual text filter in LogManager and implemented auto-escaping for invalid regex patterns to prevent logcat capture failures. - Updated startEventWatch in LogManager to use runBlockingLogcatCommand for clearing logs, ensuring that process streams are properly closed to prevent resource leaks. - Fixed collectLogTags.gradle to support Configuration Cache, implemented full tag collection using global regex matching, and repaired the manual-override workflow to ensure build stability when src/main/java is excluded. - Refined collectLogTags.gradle regex to intelligently distinguish between Log TAGs and other variables (like preference keys) using word boundaries and negative lookaheads. - Deleted commented out code related to the removed `winnative_chat_background`. --- app/src/main/runtime/system/LogManager.kt | 62 ++++++--- .../system/SessionKeepAliveService.java | 1 - .../main/shared/android/NotificationHelper.kt | 24 ---- gradle/collectLogTags.gradle | 120 ++++++++++-------- 4 files changed, 110 insertions(+), 97 deletions(-) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index ec216f660..db820326b 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -23,6 +23,10 @@ import java.util.Date import java.util.Locale import androidx.core.content.edit import com.winlator.cmod.BuildConfig +import androidx.core.net.toUri +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter object LogManager { private const val TAG = "LogManager" @@ -35,7 +39,7 @@ object LogManager { private var appLogProcess: Process? = null private var eventWatchProcess: Process? = null - private val timestampFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + private val logTimestampFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) enum class Level(val prefix: String) { DEBUG("D"), INFO("I"), WARN("W"), ERROR("E") } @@ -112,6 +116,7 @@ object LogManager { @Volatile private var cachedLogsDir: File? = null @Volatile private var manualTextFilter: String? = null + @Volatile private var manualTextFilterPattern: Regex? = null private var prefsListener: SharedPreferences.OnSharedPreferenceChangeListener? = null @@ -232,11 +237,11 @@ object LogManager { private fun resolvePathString(uriStr: String?, fallback: String, ctx: Context): String { if (uriStr.isNullOrEmpty()) return fallback return try { - val uri = Uri.parse(uriStr) - FileUtils.getFilePathFromUri(ctx, uri) ?: uriStr + val uri = uriStr.toUri() + FileUtils.getFilePathFromUri(ctx, uri) ?: fallback } catch (e: Exception) { logW(TAG,e, ctx) { "Failed to resolve winlator_path_uri ($uriStr): ${e.message}" } - uriStr + fallback } } @@ -334,7 +339,17 @@ object LogManager { /** Transient only — never written to SharedPreferences. Pass null/blank to clear. */ @JvmStatic fun setManualTextFilter(text: String?) { - manualTextFilter = text?.trim()?.takeIf { it.isNotEmpty() } + val input = text?.trim()?.takeIf { it.isNotEmpty() } + manualTextFilter = input + manualTextFilterPattern = input?.let { + try { + // Attempt to compile as a case-insensitive regex + Regex(it, RegexOption.IGNORE_CASE) + } catch (e: Exception) { + // If regex is invalid (e.g. "C++"), treat as a literal by escaping metacharacters + Regex(Regex.escape(it), RegexOption.IGNORE_CASE) + } + } } @JvmStatic @@ -551,7 +566,7 @@ object LogManager { if (!cachedAppDebugEnabled) return if (!passesTagFilter(tag)) return - manualTextFilter?.let { if (!message.contains(it, ignoreCase = true)) return } + manualTextFilterPattern?.let { if (!it.containsMatchIn(message)) return } val ctx = resolveContext(context) ?: return val fullMessage = if (t != null) "$message :: ${Log.getStackTraceString(t)}" else message @@ -560,7 +575,8 @@ object LogManager { private fun appendLine(context: Context, fileName: String, level: String, message: String) { try { - File(getLogsDir(context), fileName).appendText("${timestampFormat.format(Date())} $level: $message\n") + val now = LocalDateTime.now().format(logTimestampFormatter) + File(getLogsDir(context), fileName).appendText("$now $level: $message\n") } catch (e: Exception) { // Need to be Log for when baseLog and Timber doesn't work. Log.e(TAG, "Failed to append to $fileName: ${e.message}", e) @@ -592,18 +608,26 @@ object LogManager { 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() + runBlockingLogcatCommand(arrayOf("logcat", "-c")) val safeLabel = label.ifBlank { "manual" }.replace(Regex("[^A-Za-z0-9_-]"), "_") - val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date()) + val stamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss", Locale.US)) 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 { + manualTextFilter?.let { filter -> command.add("-e") - command.add(it) + val safeFilter = try { + java.util.regex.Pattern.compile(filter) + filter + } catch (e: Exception) { + // Portable escape for logcat's regex engine (RE2/libc) + filter.replace(Regex("[\\\\.^$\\[\\]*+?{}()|]"), "\\\\$0") + } + command.add(safeFilter) + command.add("-i") // Ensures logcat match is case-insensitive like baseLog } eventWatchProcess = Runtime.getRuntime().exec(command.toTypedArray()) @@ -708,6 +732,7 @@ object LogManager { val key = exitKey(info) val exitKey = "exit_$key" val crashKey = "crash_$key" + val timestamp = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(info.timestamp), ZoneId.systemDefault()).format(logTimestampFormatter) if (cachedExitReasonLogEnabled && exitKey !in loggedKeys) { // Separator line with reason number: 0 = newest/last, larger = older @@ -719,7 +744,7 @@ object LogManager { appendLine( ctx, EXIT_REASONS_FILE, "I/$TAG", "pid=${info.pid} reason=${info.reason}-[${getExitReasonName(info.reason)}] status=${info.status} " + - "importance=${info.importance} desc=${info.description} timestamp=${Date(info.timestamp)}", + "importance=${info.importance} desc=${info.description} timestamp=${timestamp}", ) loggedKeys.add(exitKey) wroteSomething = true @@ -747,7 +772,7 @@ object LogManager { appendLine( ctx, CRASH_FILE, "I/$TAG", "\n=== Historical $type Detected ===\n" + - "PID: ${info.pid} | Timestamp: ${Date(info.timestamp)}\n" + + "PID: ${info.pid} | Timestamp: ${timestamp}\n" + "Description: ${info.description}\n" + "Trace Summary:\n$summary\n" + "=== End $type Report ===" @@ -791,7 +816,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 = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss_SSS", Locale.US)) val fileName = "crashFromThread_$timestamp.log" val file = File(getLogsDir(context), fileName) @@ -856,9 +881,11 @@ object LogManager { @RequiresApi(Build.VERSION_CODES.R) private fun buildPostMortemReport(info: ApplicationExitInfo, context: Context): String { + val timestamp = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(info.timestamp), ZoneId.systemDefault()).format(logTimestampFormatter) + return buildString { appendLine("=== POST-MORTEM [${getExitReasonName(info.reason)}] ===") - appendLine("Time : ${Date(info.timestamp)}") + appendLine("Time : $timestamp") appendLine("PID : ${info.pid}") appendLine("Reason : ${info.reason} [${getExitReasonName(info.reason)}]") appendLine("Status : ${info.status}") @@ -889,7 +916,10 @@ 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 exitKey(info: ApplicationExitInfo): String { + val timestamp = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(info.timestamp), ZoneId.systemDefault()).format(logTimestampFormatter) + return "${info.pid}_$timestamp" + } private fun getPersistedKeys(prefs: SharedPreferences, prefKey: String): Set = prefs.getString(prefKey, null) diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index c4ae74c3c..6a10fd666 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -54,7 +54,6 @@ public class SessionKeepAliveService extends Service { private static volatile SessionKeepAliveService instance; private static final String TAG = "SessionKeepAlive"; - private static final String EXTRA_TAG = "SessionKeepAlive_debugTag"; private static final String ACTION_ENSURE_FOREGROUND = "com.winlator.cmod.action.ENSURE_FOREGROUND"; diff --git a/app/src/main/shared/android/NotificationHelper.kt b/app/src/main/shared/android/NotificationHelper.kt index e2b949222..48121e78a 100644 --- a/app/src/main/shared/android/NotificationHelper.kt +++ b/app/src/main/shared/android/NotificationHelper.kt @@ -30,15 +30,6 @@ class NotificationHelper private const val CHAT_CHANNEL_ID = "winnative_steam_chat" private const val CHAT_CHANNEL_NAME = "Steam Chat" - /** 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" const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".EXIT" @@ -60,21 +51,6 @@ class NotificationHelper "Incoming Steam friend messages", true ) - - // 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) - } - - notificationManager.createNotificationChannel(backgroundChannel)*/ } // Sends or updates a notification. diff --git a/gradle/collectLogTags.gradle b/gradle/collectLogTags.gradle index dc6ffc70c..3cea2bc0c 100644 --- a/gradle/collectLogTags.gradle +++ b/gradle/collectLogTags.gradle @@ -2,6 +2,9 @@ // 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 srcDirsProvider = providers.gradleProperty('collectLogTags.srcDirs') +def overwriteProvider = providers.gradleProperty('collectLogTags.overwrite') + def defaultCandidates = [ 'src/main/app', 'src/main/feature', @@ -11,94 +14,99 @@ def defaultCandidates = [ ] def javaDir = file('src/main/java') +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') -def srcDirs = [] -if (project.hasProperty('collectLogTags.srcDirs')) { - srcDirs = project.property('collectLogTags.srcDirs').toString().split(',').collect { file(it.trim()) } -} else { - srcDirs = defaultCandidates.collect { file(it) } -} +def srcDirs = srcDirsProvider.isPresent() ? + srcDirsProvider.get().split(',').collect { file(it.trim()) } : + 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') { + group = 'build' + description = 'Collects all TAG variables and literal LogManager calls into a generated class.' + 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)." + + // Manual file as optional input to detect changes + if (manualOutputFile.exists()) { + inputs.file(manualOutputFile).optional().withPropertyName("manualFile") } + // Task ALWAYS produces the build-dir output to satisfy compiler requirements + outputs.file outputFile + doLast { + def overwrite = overwriteProvider.getOrElse("false").toBoolean() + + // Fix: Mirror manual file if it exists and we aren't overwriting + if (manualOutputFile.exists() && !overwrite) { + println "collectLogTags: Manual GeneratedLogTags.kt exists. Mirroring to generated path." + outputFile.parentFile.mkdirs() + outputFile.text = manualOutputFile.text + return + } + 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*"([^"]+)"/ + def literalCallSite = ~/LogManager\.(?:log|logI|logW|logE)\(\s*"([^"]+)"/ + // CLEVER REGEX: + // \b : Start at a word boundary + // (?![A-Z0-9_]*PREF_) : Look ahead and fail if the variable name contains "PREF_" + // [A-Z0-9_]* : Match optional uppercase/numeric/underscore prefix + // TAG : Match the literal word "TAG" + // \b : Ensure "TAG" is the end of the word (excludes TAGS, TAG_FILTER) + def genericTagDecl = ~/\b(?![A-Z0-9_]*PREF_)([A-Z0-9_]*TAG)\b\s*=\s*"([^"]+)"/ inputs.files.each { f -> - if (!f.isFile()) return + if (!f.isFile() || f == manualOutputFile) 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). + // Safety: Skip anything physically or logically inside src/main/java junctions + if (f.path.contains("src/main/java") || f.path.contains("src\\main\\java")) return try { - if (javaDir.exists() && f.canonicalFile.path.startsWith(javaDir.canonicalFile.path)) return - } catch (ignored) { - // ignore canonicalization failures and continue - } + if (javaDir.exists() && f.canonicalPath.startsWith(javaDir.canonicalPath)) return + } catch (ignored) {} 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] - } - } + // Efficiently collect ALL unique matches in the file + text.findAll(literalCallSite) { match -> tags.add(match[1]) } + text.findAll(genericTagDecl) { match -> tags.add(match[2]) } - def resolved = literalTag ?: declaredTag ?: f.name.replaceFirst(/\.[^.]+$/, '') - tags.add(resolved) + // Filename fallback if no tags found but LogManager is used + if (tags.isEmpty()) { + tags.add(f.name.replaceFirst(/\.[^.]+$/, '')) + } } - // 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 - } + def generatedContent = """package com.winlator.cmod.runtime.system + +/** Auto-generated by the collectLogTags Gradle task. Do not edit by hand. */ +object GeneratedLogTags { + val TAGS: Set = setOf( +${tags.collect { " \"$it\"" }.join(",\n")} + ) +} +""" 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 '}' + outputFile.text = generatedContent + + if (overwrite) { + manualOutputFile.parentFile.mkdirs() + manualOutputFile.text = generatedContent + println "collectLogTags: Overwrote manual file at ${manualOutputFile}" } - println "collectLogTags: wrote ${outputFile} with ${tags.size()} tags" + println "collectLogTags: wrote ${outputFile} with ${tags.size()} tags." } } From e77fa52064a0b1a41464eab28c3909ff4b14a734 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Thu, 16 Jul 2026 21:12:40 +0200 Subject: [PATCH 39/62] Some bug fixes and improvements (5). - Fixed a race condition in SteamService registration where asynchronous stop() calls could incorrectly unregister new instances in SessionKeepAliveService, by tying component tracking to specific owner instances. - Implemented thread-safe event handling in EventDispatcher using ConcurrentHashMap and CopyOnWriteArrayList to resolve the root cause of ConcurrentModificationException when mutating listeners from multiple threads. - Related to the game start up infinite loop caused by a crash issue that was patched but not fixed. - Corrected onTimeout implementation in SessionKeepAliveService and SteamService to prevent process leaks by routing through proper session teardown, and added documentation regarding its reachability at the current target SDK. - Added `tools:ignore="ProtectedPermissions"` back to the READ_LOGS permission in the Manifest. - Fixed MultiSelectDialog in DebugScreen to prevent unconfirmed selection resets when modifying custom tags and to ensure gamepad confirmation uses the latest options and filter mode. - Refined DebugFragment and DebugScreen state management by ensuring allLogTagOptions refreshes upon tag changes and preventing premature clearing of the manual filter when event-watching is still active. - Fixed the heartbeat frequency field in OtherSettingsScreen to prevent redundant writes on layout attachment and correctly handle Int overflow by clamping to Int.MAX_VALUE instead of resetting to 0. --- app/src/main/AndroidManifest.xml | 2 +- .../feature/settings/debug/DebugFragment.kt | 5 +- .../feature/settings/debug/DebugScreen.kt | 13 ++++-- .../settings/other/OtherSettingsScreen.kt | 46 +++++++++++++------ .../stores/steam/events/EventDispatcher.kt | 24 ++++++---- .../stores/steam/service/SteamService.kt | 15 +++--- .../stores/steam/ui/SteamLoginViewModel.kt | 2 + .../system/SessionKeepAliveService.java | 27 ++++++++--- 8 files changed, 91 insertions(+), 43 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ecfb13182..4b36f816d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,7 +36,7 @@ - + diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index 213c283ca..688ce20f4 100644 --- a/app/src/main/feature/settings/debug/DebugFragment.kt +++ b/app/src/main/feature/settings/debug/DebugFragment.kt @@ -87,6 +87,9 @@ class DebugFragment : Fragment() { .updateLoggingState(ctx) com.winlator.cmod.runtime.system.LogManager .clearManualTextFilter() + if (!debugState.eventWatchLog) { + com.winlator.cmod.runtime.system.LogManager.clearManualTextFilter() + } } refresh() }, @@ -119,7 +122,7 @@ class DebugFragment : Fragment() { 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() }, + allLogTagOptions = remember(debugState.customTags) { LogManager.getAllKnownTags() }, onWineDebugChanged = { checked -> preferences.edit { putBoolean("enable_wine_debug", checked) } com.winlator.cmod.runtime.system.LogManager diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index 6a7e8fc2a..8f2c62772 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -361,6 +361,7 @@ fun DebugScreen( exit = fadeOut() + shrinkVertically(), ) { var manualFilterText by remember { mutableStateOf(LogManager.getManualTextFilter()) } + LaunchedEffect(state.appDebug, state.eventWatchLog) { manualFilterText = LogManager.getManualTextFilter() } val focusManager = LocalFocusManager.current OutlinedTextField( value = manualFilterText, @@ -1022,7 +1023,7 @@ private fun MultiSelectDialog( maxWidth: Dp = 460.dp, ) { val selected = - remember(initiallySelected) { + remember { mutableStateOf(initiallySelected.toSet()) } val contentRegistry = remember { PaneNavRegistry().apply { stableCursor = true } } @@ -1033,6 +1034,10 @@ private fun MultiSelectDialog( val density = LocalDensity.current var viewportTop by remember { mutableStateOf(0f) } var viewportHeight by remember { mutableIntStateOf(0) } + // Capture latest options and callbacks to avoid stale data in remembered handlers + val currentOptions by rememberUpdatedState(options) + val currentOnConfirm by rememberUpdatedState(onConfirm) + val currentOnDismiss by rememberUpdatedState(onDismiss) LaunchedEffect(contentRegistry.activeRow, contentRegistry.activeCol, viewportHeight, footerZone) { if (footerZone || !contentRegistry.controllerActive || contentRegistry.manualSelection) return@LaunchedEffect @@ -1073,10 +1078,10 @@ private fun MultiSelectDialog( val handlers = remember { paneNavHandlers( - onDismiss = onDismiss, + onDismiss = { currentOnDismiss() }, onStart = { - val ordered = options.filter { it in selected.value } - onConfirm(ordered) + val ordered = currentOptions.filter { it in selected.value } + currentOnConfirm(ordered) }, registry = { if (footerZone) footerRegistry else contentRegistry }, ) diff --git a/app/src/main/feature/settings/other/OtherSettingsScreen.kt b/app/src/main/feature/settings/other/OtherSettingsScreen.kt index 60841abb9..3707861c7 100644 --- a/app/src/main/feature/settings/other/OtherSettingsScreen.kt +++ b/app/src/main/feature/settings/other/OtherSettingsScreen.kt @@ -1254,15 +1254,22 @@ private fun HeartbeatFrequencyCard( var rawText by remember(currentFrequency) { mutableStateOf(currentFrequency.toString()) } val focusManager = LocalFocusManager.current + // Tracks internal focus to prevent redundant commit on initial composition/attach + var isFocused by remember { mutableStateOf(false) } + // 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) + // Use Long to handle Int overflow detection correctly + val parsedLong = rawText.trim().toLongOrNull() + val isOverflow = rawText.isNotEmpty() && (parsedLong == null || parsedLong > Int.MAX_VALUE) + val isTooSmall = parsedLong != null && parsedLong != 0L && parsedLong < 5 + val isError = isOverflow || isTooSmall + 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) + rawText.isEmpty() || parsedLong == 0L -> stringResource(R.string.settings_other_bg_heartbeat_disabled) + isOverflow -> stringResource(R.string.settings_other_bg_heartbeat_effective, Int.MAX_VALUE) + isTooSmall -> stringResource(R.string.settings_other_bg_heartbeat_minimum) + else -> stringResource(R.string.settings_other_bg_heartbeat_effective, parsedLong!!.toInt()) } Box( @@ -1352,7 +1359,6 @@ private fun HeartbeatFrequencyCard( keyboardActions = KeyboardActions( onDone = { focusManager.clearFocus() - commitFrequency(parsed, onFrequencyChanged) }, ), modifier = Modifier @@ -1374,9 +1380,10 @@ private fun HeartbeatFrequencyCard( .onFocusChanged { focus -> // Commit and clamp when the user leaves the field, // so tapping elsewhere still saves the value. - if (!focus.isFocused) { - commitFrequency(parsed, onFrequencyChanged) + if (isFocused && !focus.isFocused) { + commitFrequency(rawText, currentFrequency, onFrequencyChanged) } + isFocused = focus.isFocused }, suffix = { Text("s", color = TextSecondary, fontSize = 13.sp) }, supportingText = effectiveLabel.let { label -> @@ -1396,11 +1403,22 @@ private fun HeartbeatFrequencyCard( } // Extracted so both Done-action and focus-loss share identical clamping logic. -private fun commitFrequency(parsed: Int?, onFrequencyChanged: (Int) -> Unit) { +private fun commitFrequency(raw: String, current: Int, onFrequencyChanged: (Int) -> Unit) { + val trimmed = raw.trim() + if (trimmed.isEmpty()) { + if (current != 0) onFrequencyChanged(0) + return + } + + val parsedLong = trimmed.toLongOrNull() val committed = when { - parsed == null || parsed == 0 -> 0 // revert to default on empty / non-numeric -> disabled - parsed < 5 -> 5 // clamp to minimum - else -> parsed + parsedLong == null || parsedLong > Int.MAX_VALUE -> Int.MAX_VALUE // Overflow -> clamp to Int.MAX + parsedLong == 0L -> 0 // revert to default on empty / non-numeric -> disabled + parsedLong < 5 -> 5 // clamp to minimum + else -> parsedLong.toInt() + } + + if (committed != current) { + onFrequencyChanged(committed) } - onFrequencyChanged(committed) } diff --git a/app/src/main/feature/stores/steam/events/EventDispatcher.kt b/app/src/main/feature/stores/steam/events/EventDispatcher.kt index 4394d4150..9895110d8 100644 --- a/app/src/main/feature/stores/steam/events/EventDispatcher.kt +++ b/app/src/main/feature/stores/steam/events/EventDispatcher.kt @@ -1,10 +1,13 @@ package com.winlator.cmod.feature.stores.steam.events +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList import kotlin.reflect.KClass sealed interface Event class EventDispatcher { - val listeners = mutableMapOf>, MutableList, *>>>>() + // Use ConcurrentHashMap and CopyOnWriteArrayList for thread safety across Main and IO threads + val listeners = ConcurrentHashMap>, MutableList, *>>>>() open class EventListener, T>( val listener: (E) -> T, @@ -36,7 +39,9 @@ class EventDispatcher { listener(event as E) }, once), ) - listeners.getOrPut(eventClass) { mutableListOf() }.add(typedListener as Pair, *>>) + // computeIfAbsent is atomic in ConcurrentHashMap + listeners.computeIfAbsent(eventClass) { CopyOnWriteArrayList() } + .add(typedListener as Pair, *>>) } inline fun , T> off(noinline listener: (E) -> T) { @@ -47,11 +52,10 @@ class EventDispatcher { } inline fun > clearAllListenersOf() { - val currentKeys = listeners.keys.toList() - for (key in currentKeys) { - if (key is E) { - listeners.remove(key) - } + val targetClass = E::class + // Correctly identify keys that are subclasses of the target event class + listeners.keys.removeIf { key -> + targetClass.java.isAssignableFrom(key.java) } } @@ -70,7 +74,8 @@ class EventDispatcher { null }, false) val typedListener = Pair(listener.toString(), eventListener as EventListener, *>) - listeners.getOrPut(eventClass) { mutableListOf() }.add(typedListener) + listeners.computeIfAbsent(eventClass) { CopyOnWriteArrayList() } + .add(typedListener) } fun offJava( @@ -89,9 +94,9 @@ class EventDispatcher { ): T? { val eventClass = E::class return listeners[eventClass]?.let { eventListeners -> + // CopyOnWriteArrayList iterator is safe for concurrent modification val results = eventListeners - .toList() .map { eventListener -> val result = eventListener.second.listener(event) if (result == null && Unit is T) Unit as T else result as T @@ -107,7 +112,6 @@ class EventDispatcher { return listeners[eventClass]?.let { eventListeners -> val results = eventListeners - .toList() .map { eventListener -> eventListener.second.listener(event) }.toTypedArray() diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 936f6cbe7..319d83818 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -7677,6 +7677,8 @@ class SteamService : Service() { // Register Steam component in the master foreground service if (isRunning && !isStopping) { SessionKeepAliveService.startComponent(this, SessionKeepAliveService.COMPONENT_STEAM, "Connected") + // Bridge: Clear any registration from SteamLoginViewModel.retryConnection which uses application context + SessionKeepAliveService.stopComponent(applicationContext, SessionKeepAliveService.COMPONENT_STEAM) } return START_STICKY @@ -8090,12 +8092,8 @@ class SteamService : Service() { suspendedForBionic = false appInForeground = true - runCatching { - PluviaApp.events.off(onEndProcess) - } - runCatching { - PluviaApp.events.clearAllListenersOf>() - } + PluviaApp.events.off(onEndProcess) + PluviaApp.events.clearAllListenersOf>() } // region [REGION] WN-Steam-Client lifecycle @@ -9109,6 +9107,11 @@ class SteamService : Service() { } override fun onTimeout(startId: Int, fstype: Int) { + /* + * Note: This callback is unreachable at targetSdk 28 (requires API 34+). + * It is implemented for forward compatibility to ensure that if the system + * enforces a timeout, we stop the service gracefully (which triggers full cleanup). + */ super.onTimeout(startId, fstype) Timber.w("SteamService reached 6-hour limit for dataSync foreground service. Stopping gracefully.") diff --git a/app/src/main/feature/stores/steam/ui/SteamLoginViewModel.kt b/app/src/main/feature/stores/steam/ui/SteamLoginViewModel.kt index e3fb7f661..a90d5ae2e 100644 --- a/app/src/main/feature/stores/steam/ui/SteamLoginViewModel.kt +++ b/app/src/main/feature/stores/steam/ui/SteamLoginViewModel.kt @@ -287,6 +287,8 @@ class SteamLoginViewModel : ViewModel() { SessionKeepAliveService.startComponent(context, SessionKeepAliveService.COMPONENT_STEAM, "Restarting SteamService in retryConnection") } catch (e: Exception) { Timber.e(e, "Failed to restart SteamService in retryConnection") + // Safety: unregister if the service failed to even attempt starting + SessionKeepAliveService.stopComponent(context, SessionKeepAliveService.COMPONENT_STEAM) } }, 1000) } catch (e: Exception) { diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 6a10fd666..9fbb6c925 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -105,7 +105,7 @@ public class SessionKeepAliveService extends Service { private static final String NOTIFICATION_ID_NAME = "winnative.keepAlive"; // Tracks active components and their notification messages - private static final Map activeComponents = new ConcurrentHashMap<>(); + private static final Map> activeComponents = new ConcurrentHashMap<>(); // =================================================================== // Container / game session lifecycle @@ -219,16 +219,24 @@ public static void setActiveXServer(XServer xServer) { public static void startComponent(Context ctx, String componentName, String message) { if (ctx == null || componentName == null) return; - activeComponents.put(componentName, message != null ? message : ""); + Map owners = activeComponents.computeIfAbsent(componentName, k -> new ConcurrentHashMap<>()); + owners.put(ctx, message != null ? message : ""); LogManager.log(TAG, "startComponent: " + componentName, ctx); updateForegroundState(ctx); } 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); + Map owners = activeComponents.get(componentName); + if (owners != null) { + if (owners.remove(ctx) != null) { + if (owners.isEmpty()) { + activeComponents.remove(componentName); + } + LogManager.log(TAG, "stopComponent: " + componentName + " (owner: " + ctx + ")", ctx); + updateForegroundState(ctx); + } + } } public static boolean isAppInBackground() { return isAppInBackground; } @@ -467,12 +475,17 @@ private void stopForegroundCompat() { @Override public void onTimeout(int startId, int fstype) { + /* + * Note: This callback is unreachable while targetSdk is 28 (requires API 34+). + * If the SDK is bumped in the future, we must route through stopSession to ensure + * that environment components are torn down and wine processes are not leaked, + * rather than just stopping the service while they are in a protected/frozen state. + */ 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; + stopSession(this); stopForegroundCompat(); stopSelf(); } From bfc198297d4c533bffcce45c494555d47feaffde Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Thu, 16 Jul 2026 23:31:13 +0200 Subject: [PATCH 40/62] Some bug fixes and improvements (6). - Removed dead code: - SessionKeepAliveService - Some variables. - runOomSweep method. - Epic/GOG services - Removed appInForeground unused variable. - Removed unused imports. - ProcessHelper - SteamService - Fixes - ProcessHelper: setBackgroundPauseMode wasn't defaulting to GAME_ONLY. - English String typo. - `lifecycleProcess` version mismatch between build.gradle and libs.versions.toml. Set to 2.9.0 to avoid Gradle plugin update requirement. - Number mismatch in lastExitReasons logs. --- app/build.gradle | 2 +- .../feature/settings/other/OtherSettingsFragment.kt | 2 ++ .../main/feature/stores/epic/service/EpicService.kt | 8 -------- .../main/feature/stores/gog/service/GOGService.kt | 5 ----- .../feature/stores/steam/service/SteamService.kt | 1 - app/src/main/res/values/strings.xml | 2 +- .../main/runtime/display/ui/XServerSurfaceView.java | 12 ++++++------ app/src/main/runtime/system/LogManager.kt | 2 +- app/src/main/runtime/system/ProcessHelper.java | 4 +--- .../main/runtime/system/SessionKeepAliveService.java | 9 --------- app/src/main/shared/android/NotificationHelper.kt | 2 -- gradle/libs.versions.toml | 2 +- 12 files changed, 13 insertions(+), 38 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7312e640f..397b4e1ea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -280,7 +280,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(libs.lifecycleProcess) // 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/other/OtherSettingsFragment.kt b/app/src/main/feature/settings/other/OtherSettingsFragment.kt index 9484c90a2..207faed2b 100644 --- a/app/src/main/feature/settings/other/OtherSettingsFragment.kt +++ b/app/src/main/feature/settings/other/OtherSettingsFragment.kt @@ -167,6 +167,8 @@ class OtherSettingsFragment : Fragment() { }, onEnableBackgroundSessionChanged = { checked -> preferences.edit { putBoolean("enable_background_session", checked) } + // This is true if Store services are already running, but it isn't when is auto-enabled on wizard. + WinToast.show(ctx, R.string.settings_general_take_effect_next_startup) refresh() }, onEnableAutoPauseChanged = { checked -> diff --git a/app/src/main/feature/stores/epic/service/EpicService.kt b/app/src/main/feature/stores/epic/service/EpicService.kt index 1afe6a8cc..7879b5647 100644 --- a/app/src/main/feature/stores/epic/service/EpicService.kt +++ b/app/src/main/feature/stores/epic/service/EpicService.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.Intent import android.os.IBinder import com.winlator.cmod.BuildConfig -import com.winlator.cmod.R import com.winlator.cmod.app.PluviaApp import com.winlator.cmod.app.db.download.DownloadRecord import com.winlator.cmod.app.service.DownloadService @@ -17,7 +16,6 @@ import com.winlator.cmod.feature.stores.epic.data.EpicGameToken import com.winlator.cmod.feature.stores.epic.ui.util.SnackbarManager import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety import com.winlator.cmod.feature.stores.steam.data.DownloadInfo -import com.winlator.cmod.feature.stores.steam.data.LaunchInfo import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase import com.winlator.cmod.feature.stores.steam.enums.Marker import com.winlator.cmod.feature.stores.steam.events.AndroidEvent @@ -26,7 +24,6 @@ import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils import com.winlator.cmod.feature.stores.steam.utils.PrefManager import com.winlator.cmod.runtime.system.SessionKeepAliveService import com.winlator.cmod.shared.android.AppTerminationHelper -import com.winlator.cmod.shared.android.NotificationHelper import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import timber.log.Timber @@ -35,10 +32,6 @@ 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 - // Service facade for Epic auth, library sync, downloads, and cloud saves. @AndroidEntryPoint class EpicService : Service() { @@ -80,7 +73,6 @@ class EpicService : Service() { val isRunning: Boolean get() = instance != null - @Volatile private var appInForeground = true fun start(context: Context) { Timber.tag("EPIC").d("Starting service...") diff --git a/app/src/main/feature/stores/gog/service/GOGService.kt b/app/src/main/feature/stores/gog/service/GOGService.kt index 813eb5561..8cd778058 100644 --- a/app/src/main/feature/stores/gog/service/GOGService.kt +++ b/app/src/main/feature/stores/gog/service/GOGService.kt @@ -22,7 +22,6 @@ import com.winlator.cmod.feature.sync.google.GameSaveBackupManager.BackupResult import com.winlator.cmod.runtime.container.Container import com.winlator.cmod.runtime.system.SessionKeepAliveService import com.winlator.cmod.shared.android.AppTerminationHelper -import com.winlator.cmod.shared.android.NotificationHelper import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import timber.log.Timber @@ -34,10 +33,6 @@ 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 - // Service facade for GOG auth, library sync, downloads, and cloud saves. @AndroidEntryPoint class GOGService : Service() { diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 319d83818..95f8b8529 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -6943,7 +6943,6 @@ class SteamService : Service() { fun start(context: Context) { try { - val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context) val intent = Intent(context, SteamService::class.java) // Just start as a normal service. KeepAliveService should protect this. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c371ade5..82081962a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1145,7 +1145,7 @@ E.g. META for META key, \n 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. + Suspends only the active game process; other processes and services stay running. Pause all except the game Suspends all processes and services but keeps the game process active. Auto pause session diff --git a/app/src/main/runtime/display/ui/XServerSurfaceView.java b/app/src/main/runtime/display/ui/XServerSurfaceView.java index 8b29be2a1..f3dfd3089 100644 --- a/app/src/main/runtime/display/ui/XServerSurfaceView.java +++ b/app/src/main/runtime/display/ui/XServerSurfaceView.java @@ -100,7 +100,7 @@ public void onResume() { paused = false; renderRequested = true; renderLock.notifyAll(); - LogManager.log(TAG, "onResume: inside [synchronized (renderLock)]"); +// LogManager.log(TAG, "onResume: inside [synchronized (renderLock)]"); } LogManager.log(TAG, "onResume called"); } @@ -109,7 +109,7 @@ public void onPause() { synchronized (renderLock) { paused = true; renderLock.notifyAll(); - LogManager.log(TAG, "onPause: inside [synchronized (renderLock)]"); +// LogManager.log(TAG, "onPause: inside [synchronized (renderLock)]"); } LogManager.log(TAG, "onPause called"); } @@ -124,7 +124,7 @@ public void surfaceCreated(SurfaceHolder holder) { surfaceReady = false; width = 0; height = 0; - LogManager.log(TAG, "surfaceCreated: inside [synchronized (renderLock)]"); +// LogManager.log(TAG, "surfaceCreated: inside [synchronized (renderLock)]"); } renderer.attachSurface(holder.getSurface()); startRenderThreadIfNeeded(); @@ -150,7 +150,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"); +// LogManager.log(TAG, "surfaceChanged: inside [synchronized (renderLock)] return"); } return; } @@ -163,7 +163,7 @@ 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: inside [synchronized (renderLock)] second"); } LogManager.log(TAG, "surfaceChanged called"); } @@ -175,7 +175,7 @@ public void surfaceDestroyed(SurfaceHolder holder) { width = 0; height = 0; renderLock.notifyAll(); - LogManager.log(TAG, "surfaceDestroyed: inside [synchronized (renderLock)]"); +// LogManager.log(TAG, "surfaceDestroyed: inside [synchronized (renderLock)]"); } // Run the render thread one more iteration so it sees surfaceReady=false and exits. stopRenderThread(); diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index db820326b..177c51ba0 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -738,7 +738,7 @@ object LogManager { // Separator line with reason number: 0 = newest/last, larger = older appendLine( ctx, EXIT_REASONS_FILE, "I/$TAG", - "\n---- Exit reason #${index} (0=new/last, ${maxExitReasons}=oldest) ----" + "\n---- Exit reason #${index+1} (1=new/last, ${maxExitReasons}=oldest) ----" ) appendLine( diff --git a/app/src/main/runtime/system/ProcessHelper.java b/app/src/main/runtime/system/ProcessHelper.java index e669171a5..05988a992 100644 --- a/app/src/main/runtime/system/ProcessHelper.java +++ b/app/src/main/runtime/system/ProcessHelper.java @@ -299,8 +299,6 @@ public static void pauseAllWineProcesses() { default: break; } - -// suspendProcess(pid); } } @@ -733,7 +731,7 @@ private static String trimProcessDetail(String detail) { } public static void setBackgroundPauseMode(BackgroundPauseMode mode) { - backgroundPauseMode = mode != null ? mode : BackgroundPauseMode.ALL; + backgroundPauseMode = mode != null ? mode : BackgroundPauseMode.GAME_ONLY; } public static BackgroundPauseMode getBackgroundPauseMode() { diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 9fbb6c925..3207dc99f 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -59,10 +59,6 @@ public class SessionKeepAliveService extends Service { 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"; public static final String COMPONENT_STEAM = "Steam"; public static final String COMPONENT_EPIC = "Epic"; @@ -595,11 +591,6 @@ private void stopHeartbeat() { } } - private void runOomSweep() { - new Thread(this::runOomSweepInternal, "SessionOomProtection").start(); - LogManager.log(TAG, "OOM protection sweep started", this); - } - private void runOomSweepInternal() { try { ProcessHelper.protectAllWineProcesses(); diff --git a/app/src/main/shared/android/NotificationHelper.kt b/app/src/main/shared/android/NotificationHelper.kt index 48121e78a..4de5d3823 100644 --- a/app/src/main/shared/android/NotificationHelper.kt +++ b/app/src/main/shared/android/NotificationHelper.kt @@ -6,12 +6,10 @@ 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 javax.inject.Inject diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 20ff18494..445c613ce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ xz = "1.12" commonsCompress = "1.28.0" spotless = "8.5.1" workManager = "2.11.2" -lifecycleProcess = "2.11.0" +lifecycleProcess = "2.9.0" # New = compilation errors / gradle plugin update requirement. [libraries] okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } From b688eec6c3272ca4f65d95bddf9157dd57a21ad1 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Fri, 17 Jul 2026 18:53:17 +0200 Subject: [PATCH 41/62] Split general Application Log toggle behaviour into the default and general behaviour and the filtered log mechanic. - Separated application logging into two independent features: 'Filtered Breadcrumbs' (using the tag-based filtering system) and 'General Logcat' (capturing raw system output), each with its own toggle and control logic. --- .../feature/settings/debug/DebugFragment.kt | 7 +++++ .../feature/settings/debug/DebugScreen.kt | 26 +++++++++++++----- app/src/main/res/values/strings.xml | 2 ++ app/src/main/runtime/system/LogManager.kt | 27 ++++++++++--------- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index 688ce20f4..266412f4e 100644 --- a/app/src/main/feature/settings/debug/DebugFragment.kt +++ b/app/src/main/feature/settings/debug/DebugFragment.kt @@ -85,6 +85,12 @@ class DebugFragment : Fragment() { .stopAppLogging() com.winlator.cmod.runtime.system.LogManager .updateLoggingState(ctx) + } + refresh() + }, + onFilteredLogsChanged = { checked -> + preferences.edit { putBoolean("enable_filtered_logs", checked) } + if (!checked) { com.winlator.cmod.runtime.system.LogManager .clearManualTextFilter() if (!debugState.eventWatchLog) { @@ -244,6 +250,7 @@ class DebugFragment : Fragment() { debugState = DebugState( appDebug = preferences.getBoolean("enable_app_debug", false), + filteredLogs = preferences.getBoolean("enable_filtered_logs", false), exitReasonLog = preferences.getBoolean("enable_exit_reason_log", false), crashLog = preferences.getBoolean("enable_crash_log", false), eventWatchLog = preferences.getBoolean("enable_event_watch_log", false), diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index 8f2c62772..c2e313be9 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -164,6 +164,7 @@ private val SystemTagColor = Color(0xFFFFCC00) // State data class DebugState( val appDebug: Boolean = false, + val filteredLogs: Boolean = false, val exitReasonLog: Boolean = false, val crashLog: Boolean = false, val eventWatchLog: Boolean = false, @@ -200,6 +201,7 @@ fun DebugScreen( wineClassOptions: List, allLogTagOptions: List, // LogManager.getAllKnownTags() onAppDebugChanged: (Boolean) -> Unit, + onFilteredLogsChanged: (Boolean) -> Unit, onExitReasonLogChanged: (Boolean) -> Unit, onCrashLogChanged: (Boolean) -> Unit, onEventWatchLogChanged: (Boolean) -> Unit, @@ -312,11 +314,21 @@ fun DebugScreen( onCheckedChange = onAppDebugChanged, ) + SettingsToggleCard( + title = stringResource(R.string.settings_filtered_logs_to_file_title), + subtitle = stringResource(R.string.settings_filtered_logs_to_file_desc), + icon = Icons.Outlined.BugReport, + accentColor = Warning, + checked = state.filteredLogs, + onCheckedChange = onFilteredLogsChanged, + ) + 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, + accentColor = SystemTagColor, checked = state.exitReasonLog, onCheckedChange = onExitReasonLogChanged, ) @@ -326,6 +338,7 @@ fun DebugScreen( title = stringResource(R.string.settings_debug_crash_log_title), subtitle = stringResource(R.string.settings_debug_crash_log_subtitle), icon = Icons.Outlined.BugReport, + accentColor = SystemTagColor, checked = state.crashLog, onCheckedChange = onCrashLogChanged, ) @@ -334,19 +347,20 @@ fun DebugScreen( title = stringResource(R.string.settings_debug_event_watch_log_title), subtitle = stringResource(R.string.settings_debug_event_watch_log_subtitle), icon = Icons.Outlined.BugReport, + accentColor = SystemTagColor, checked = state.eventWatchLog, onCheckedChange = onEventWatchLogChanged, ) AnimatedVisibility( - visible = state.appDebug || state.eventWatchLog, + visible = state.filteredLogs || state.eventWatchLog, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { LogTagFilterCard( mode = state.tagFilterMode, selectedTags = state.selectedTags, - enabled = state.appDebug || state.eventWatchLog, + enabled = state.filteredLogs || state.eventWatchLog, onEdit = { showTagFilterDialog = true }, onModeChanged = onTagFilterModeChanged, onRemoveSelectedTag = { tag -> @@ -356,12 +370,12 @@ fun DebugScreen( } AnimatedVisibility( - visible = state.appDebug || state.eventWatchLog, + visible = state.filteredLogs || state.eventWatchLog, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { var manualFilterText by remember { mutableStateOf(LogManager.getManualTextFilter()) } - LaunchedEffect(state.appDebug, state.eventWatchLog) { manualFilterText = LogManager.getManualTextFilter() } + LaunchedEffect(state.filteredLogs, state.eventWatchLog) { manualFilterText = LogManager.getManualTextFilter() } val focusManager = LocalFocusManager.current OutlinedTextField( value = manualFilterText, @@ -371,13 +385,13 @@ fun DebugScreen( }, placeholder = { Text(stringResource(R.string.settings_debug_manual_text_filter_hint)) }, singleLine = true, - enabled = state.appDebug || state.eventWatchLog, + enabled = state.filteredLogs || 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 }, + .focusProperties { canFocus = state.filteredLogs || state.eventWatchLog }, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82081962a..4b5f05018 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1117,6 +1117,8 @@ E.g. META for META key, \n No debug logs available. Enable debugging options first and launch a game. Failed to capture logs: %1$s Log WinNative process errors, warnings and crashes to file + Filtered Debug Log + Save the app’s logs to a file, filtered according to what is configured in the tag filter. Emulation Subsystems Tools diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 177c51ba0..7b10c5df9 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -5,7 +5,6 @@ import android.app.ApplicationExitInfo import android.content.Context import android.content.SharedPreferences import android.content.pm.PackageManager -import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper @@ -18,11 +17,9 @@ 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 import androidx.core.content.edit -import com.winlator.cmod.BuildConfig import androidx.core.net.toUri import java.time.LocalDateTime import java.time.ZoneId @@ -88,6 +85,7 @@ object LogManager { private const val PREF_ENABLE_APP_DEBUG = "enable_app_debug" + private const val PREF_ENABLE_FILTERED_LOG = "enable_filtered_logs" 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" @@ -107,6 +105,8 @@ object LogManager { @Volatile private var appContext: Context? = null @Volatile var cachedAppDebugEnabled = false + @Volatile + var cachedFilteredLogEnabled = false @Volatile private var cachedExitReasonLogEnabled = false @Volatile private var cachedCrashLogEnabled = false @Volatile private var cachedEventWatchEnabled = false @@ -121,14 +121,14 @@ object LogManager { 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", + PREF_ENABLE_APP_DEBUG, PREF_ENABLE_FILTERED_LOG, 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() = cachedAppDebugEnabled + val isDebugEnabled: Boolean get() = cachedAppDebugEnabled || cachedFilteredLogEnabled @JvmStatic val isEventWatchEnabled: Boolean get() = cachedEventWatchEnabled @@ -183,6 +183,7 @@ object LogManager { private fun refreshCaches(context: Context) { val prefs = PreferenceManager.getDefaultSharedPreferences(context) cachedAppDebugEnabled = prefs.getBoolean(PREF_ENABLE_APP_DEBUG, false) + cachedFilteredLogEnabled = prefs.getBoolean(PREF_ENABLE_FILTERED_LOG, 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) @@ -520,22 +521,22 @@ object LogManager { * instead. */ inline fun log(tag: String, context: Context? = null, message: () -> String) { - if (!cachedAppDebugEnabled) return + if (!cachedAppDebugEnabled || !cachedFilteredLogEnabled) return baseLog(Level.DEBUG, tag, message(), null, context) } inline fun logI(tag: String, context: Context? = null, message: () -> String) { - if (!cachedAppDebugEnabled) return + if (!cachedAppDebugEnabled || !cachedFilteredLogEnabled) return baseLog(Level.INFO, tag, message(), null, context) } inline fun logW(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) { - if (!cachedAppDebugEnabled) return + if (!cachedAppDebugEnabled || !cachedFilteredLogEnabled) return baseLog(Level.WARN, tag, message(), t, context) } inline fun logE(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) { - if (!cachedAppDebugEnabled) return + if (!cachedAppDebugEnabled || !cachedFilteredLogEnabled) return baseLog(Level.ERROR, tag, message(), t, context) } @@ -564,7 +565,7 @@ object LogManager { } } - if (!cachedAppDebugEnabled) return + if (!cachedFilteredLogEnabled) return if (!passesTagFilter(tag)) return manualTextFilterPattern?.let { if (!it.containsMatchIn(message)) return } @@ -738,7 +739,7 @@ object LogManager { // Separator line with reason number: 0 = newest/last, larger = older appendLine( ctx, EXIT_REASONS_FILE, "I/$TAG", - "\n---- Exit reason #${index+1} (1=new/last, ${maxExitReasons}=oldest) ----" + "\n---- Exit reason #${index} (1=new/last, ${infos.size-1}=oldest) ----" ) appendLine( From 9dbf78cc81b039b7aef2f8ae58e4be4a839a236b Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Fri, 17 Jul 2026 20:22:47 +0200 Subject: [PATCH 42/62] Some options in the Debug section (UI) have been grouped into expandable groups. --- .../feature/settings/debug/DebugScreen.kt | 207 ++++++++++++------ 1 file changed, 139 insertions(+), 68 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index c2e313be9..345d65651 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -3,6 +3,7 @@ import android.os.Build import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring @@ -139,7 +140,13 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalFocusManager @@ -314,85 +321,99 @@ fun DebugScreen( onCheckedChange = onAppDebugChanged, ) - SettingsToggleCard( - title = stringResource(R.string.settings_filtered_logs_to_file_title), - subtitle = stringResource(R.string.settings_filtered_logs_to_file_desc), - icon = Icons.Outlined.BugReport, - accentColor = Warning, - checked = state.filteredLogs, - onCheckedChange = onFilteredLogsChanged, - ) + // Exit group + CollapsibleGroup( + title = stringResource(R.string.common_ui_exit), // add string resource "Exit" + accentColor = SystemTagColor, + initiallyExpanded = state.exitReasonLog || state.crashLog, + ) { + 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, + accentColor = SystemTagColor, + 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), + title = stringResource(R.string.settings_debug_crash_log_title), + subtitle = stringResource(R.string.settings_debug_crash_log_subtitle), icon = Icons.Outlined.BugReport, accentColor = SystemTagColor, - checked = state.exitReasonLog, - onCheckedChange = onExitReasonLogChanged, + checked = state.crashLog, + onCheckedChange = onCrashLogChanged, ) } - SettingsToggleCard( - title = stringResource(R.string.settings_debug_crash_log_title), - subtitle = stringResource(R.string.settings_debug_crash_log_subtitle), - icon = Icons.Outlined.BugReport, - accentColor = SystemTagColor, - checked = state.crashLog, - onCheckedChange = onCrashLogChanged, - ) - - 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, - accentColor = SystemTagColor, - checked = state.eventWatchLog, - onCheckedChange = onEventWatchLogChanged, - ) - - AnimatedVisibility( - visible = state.filteredLogs || state.eventWatchLog, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), + // Filter group + CollapsibleGroup( + title = stringResource(R.string.session_gyroscope_filtering), // add string resource "Filter" + accentColor = Warning, + initiallyExpanded = state.filteredLogs || state.eventWatchLog, ) { - LogTagFilterCard( - mode = state.tagFilterMode, - selectedTags = state.selectedTags, - enabled = state.filteredLogs || state.eventWatchLog, - onEdit = { showTagFilterDialog = true }, - onModeChanged = onTagFilterModeChanged, - onRemoveSelectedTag = { tag -> - onSelectedTagsChanged(state.selectedTags - tag) - }, + SettingsToggleCard( + title = stringResource(R.string.settings_filtered_logs_to_file_title), + subtitle = stringResource(R.string.settings_filtered_logs_to_file_desc), + icon = Icons.Outlined.BugReport, + accentColor = Warning, + checked = state.filteredLogs, + onCheckedChange = onFilteredLogsChanged, ) - } - AnimatedVisibility( - visible = state.filteredLogs || state.eventWatchLog, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - var manualFilterText by remember { mutableStateOf(LogManager.getManualTextFilter()) } - LaunchedEffect(state.filteredLogs, state.eventWatchLog) { manualFilterText = 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.filteredLogs || state.eventWatchLog, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .focusProperties { canFocus = state.filteredLogs || state.eventWatchLog }, + 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, + accentColor = SystemTagColor, + checked = state.eventWatchLog, + onCheckedChange = onEventWatchLogChanged, ) + + AnimatedVisibility( + visible = state.filteredLogs || state.eventWatchLog, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LogTagFilterCard( + mode = state.tagFilterMode, + selectedTags = state.selectedTags, + enabled = state.filteredLogs || state.eventWatchLog, + onEdit = { showTagFilterDialog = true }, + onModeChanged = onTagFilterModeChanged, + onRemoveSelectedTag = { tag -> + onSelectedTagsChanged(state.selectedTags - tag) + }, + ) + } + + AnimatedVisibility( + visible = state.filteredLogs || state.eventWatchLog, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + var manualFilterText by remember { mutableStateOf(LogManager.getManualTextFilter()) } + LaunchedEffect(state.filteredLogs, state.eventWatchLog) { manualFilterText = 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.filteredLogs || state.eventWatchLog, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .focusProperties { canFocus = state.filteredLogs || state.eventWatchLog }, + ) + } } SectionLabel(stringResource(R.string.settings_debug_section_emulation), modifier = Modifier.padding(top = 8.dp)) @@ -2331,3 +2352,53 @@ private fun Modifier.verticalScrollbar( alpha = thumbAlpha, ) } + +@Composable +private fun CollapsibleGroup( + title: String, + subtitle: String? = null, + accentColor: Color = Accent, + initiallyExpanded: Boolean = false, + content: @Composable () -> Unit, +) { + var expanded by remember { mutableStateOf(initiallyExpanded) } + + Card( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + colors = CardDefaults.cardColors(containerColor = CardDark) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.titleMedium, color = TextSecondary) + if (subtitle != null) { + Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary) + } + } + IconButton(onClick = { expanded = !expanded }) { + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand" + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column(modifier = Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + content() + } + } + } + } +} \ No newline at end of file From 511eeddccfa281849202dc2baa99ebc443ba3e77 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Fri, 17 Jul 2026 20:57:51 +0200 Subject: [PATCH 43/62] Updated translations. - Credits to this tool: https://stringstranslator.com/ --- app/src/main/res/values-b+es+419/strings.xml | 2 ++ app/src/main/res/values-da/strings.xml | 2 ++ app/src/main/res/values-de/strings.xml | 2 ++ app/src/main/res/values-es/strings.xml | 2 ++ app/src/main/res/values-fi/strings.xml | 2 ++ app/src/main/res/values-fr/strings.xml | 2 ++ app/src/main/res/values-hi/strings.xml | 2 ++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-ja/strings.xml | 2 ++ app/src/main/res/values-ko/strings.xml | 2 ++ app/src/main/res/values-no/strings.xml | 2 ++ app/src/main/res/values-pl/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 2 ++ app/src/main/res/values-pt/strings.xml | 2 ++ app/src/main/res/values-ro/strings.xml | 2 ++ app/src/main/res/values-ru/strings.xml | 2 ++ app/src/main/res/values-sv/strings.xml | 2 ++ app/src/main/res/values-th/strings.xml | 2 ++ app/src/main/res/values-tr/strings.xml | 2 ++ app/src/main/res/values-uk/strings.xml | 2 ++ app/src/main/res/values-zh-rCN/strings.xml | 2 ++ app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 22 files changed, 44 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 511fd1fd5..ab37dc875 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1101,6 +1101,8 @@ Por ejemplo, META para la tecla META, \n No hay registros de depuración disponibles. Primero habilita las opciones de depuración e inicia un juego. Error al capturar los registros: %1$s Registra en un archivo los errores, advertencias y fallos del proceso de WinNative + Registro de depuración filtrado + Guarda los registros de la aplicación en un archivo, filtrados según lo configurado en el filtro de etiquetas. Emulación Subsistemas Herramientas diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index ba83911ab..461b9d607 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -816,6 +816,8 @@ F.eks. META for META-tast, \n Ingen fejlfindingslogfiler tilgængelige. Aktiver fejlfindingsindstillinger først og start et spil. Kunne ikke indfange logfiler: %1$s Log WinNative-procesfejl, advarsler og nedbrud til fil + Filtreret fejlfindingslog + Gem appens logfiler i en fil, filtreret i henhold til det, der er konfigureret i tag-filteret. Emulering Undersystemer Værktøjer diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 80fd2008d..e9e715a96 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -816,6 +816,8 @@ Z. B. META für Meta-Taste, \n Keine Debug-Protokolle verfügbar. Bitte zuerst Debug-Optionen aktivieren und ein Spiel starten. Protokolle konnten nicht erfasst werden: %1$s WinNative-Prozessfehler, -Warnungen und -Abstürze in Datei protokollieren + Gefiltertes Debug-Protokoll + Speichert die Protokolle der App in einer Datei, gefiltert nach den Einstellungen im Tag-Filter. Emulation Subsysteme Werkzeuge diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6a44ce8e7..7c319b323 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -816,6 +816,8 @@ Ej. META para la tecla META, \n No hay registros de depuración disponibles. Activa primero las opciones de depuración e inicia un juego. Error al capturar registros: %1$s Registrar errores, advertencias y fallos del proceso de WinNative en archivo + Registro de depuración filtrado + Guarda los registros de la aplicación en un archivo, filtrados según lo configurado en el filtro de etiquetas. Emulación Subsistemas Herramientas diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 7046f3848..fe402f6ff 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1101,6 +1101,8 @@ E.g. META for META-näppäin, \n Virheenkorjauslokeja ei ole saatavilla. Ota ensin virheenkorjausasetukset käyttöön ja käynnistä peli. Lokien kaappaus epäonnistui: %1$s Kirjaa WinNative-prosessin virheet, varoitukset ja kaatumiset tiedostoon + Suodatettu vianmääritysloki + Tallenna sovelluksen lokit tiedostoon, suodatettuna tunnistesuodattimen määritysten mukaisesti. Emulointi Alijärjestelmät Työkalut diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fd4c0dc6d..297ff32ce 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -816,6 +816,8 @@ Par ex. META pour la touche META, \n Aucun journal de débogage disponible. Activez d\'abord les options de débogage puis lancez un jeu. Échec de la capture des journaux : %1$s Journaliser les erreurs, avertissements et plantages du processus WinNative dans un fichier + Journal de débogage filtré + Enregistrez les journaux de l\'application dans un fichier, filtrés selon la configuration du filtre de balises. Émulation Sous-systèmes Outils diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 9692e8138..0f1670385 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -923,6 +923,8 @@ कोई डिबग लॉग उपलब्ध नहीं है. पहले डिबगिंग विकल्प सक्षम करें और एक गेम लॉन्च करें। लॉग कैप्चर करने में विफल: %1$s फ़ाइल में WinNative प्रक्रिया त्रुटियाँ, चेतावनियाँ और क्रैश लॉग करें + फ़िल्टर किया गया डिबग लॉग + ऐप के लॉग को एक फ़ाइल में सहेजें, टैग फ़िल्टर में कॉन्फ़िगर की गई सेटिंग के अनुसार फ़िल्टर किया गया। एमुलेशन सब-सिस्टम टूल diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c86811811..d664e8597 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -816,6 +816,8 @@ Ad es. META per il tasto META, \n Nessun log di debug disponibile. Attiva prima le opzioni di debug e avvia un gioco. Impossibile acquisire i log: %1$s Registra errori, avvisi e arresti anomali del processo WinNative su file + Log di debug filtrati + Salva i log dell\'app in un file, filtrati secondo quanto configurato nel filtro dei tag. Emulazione Sottosistemi Strumenti diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 55cb78237..aea4deb9e 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1101,6 +1101,8 @@ 利用可能なデバッグログがありません。先にデバッグオプションを有効にしてゲームを起動してください。 ログの取得に失敗しました: %1$s WinNative プロセスのエラー、警告、クラッシュをファイルに記録します + フィルタされたデバッグログ + タグフィルタで設定された内容に従って、アプリのログをフィルタリングし、ファイルに保存します。 エミュレーション サブシステム ツール diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index cdec816cb..d92253adb 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -816,6 +816,8 @@ 사용 가능한 디버그 로그가 없습니다. 먼저 디버그 옵션을 활성화하고 게임을 실행하세요. 로그 캡처 실패: %1$s WinNative 프로세스 오류, 경고 및 충돌을 파일에 기록 + 필터링된 디버그 로그 + 앱의 로그를 파일에 저장합니다. 태그 필터에 설정된 대로 필터링됩니다. 에뮬레이션 하위 시스템 도구 diff --git a/app/src/main/res/values-no/strings.xml b/app/src/main/res/values-no/strings.xml index 3f10fe7b8..e191a964e 100644 --- a/app/src/main/res/values-no/strings.xml +++ b/app/src/main/res/values-no/strings.xml @@ -1101,6 +1101,8 @@ F.eks. META for META-tast, \n Ingen feilsøkingslogger tilgjengelig. Aktiver feilsøkingsalternativer først og start et spill. Kunne ikke fange logger: %1$s Logg WinNative-prosessfeil, advarsler og krasj til fil + Filtrert feilsøkingslogg + Lagre appens logger til en fil, filtrert i henhold til hva som er konfigurert i merkelappfilteret. Emulering Delsystemer Verktøy diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index f97333731..3c20af973 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -822,6 +822,8 @@ Np. META dla klawisza META, \n Brak dostępnych logów debugowania. Najpierw włącz opcje debugowania i uruchom grę. Nie udało się przechwycić logów: %1$s Zapisuj błędy, ostrzeżenia i awarie procesu WinNative do pliku + Filtrowany dziennik debugowania + Zapisz dzienniki aplikacji do pliku, przefiltrowane zgodnie z konfiguracją filtra tagów. Emulacja Podsystemy Narzędzia diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 2f99f53e6..b8ef38fd0 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -816,6 +816,8 @@ Ex. META para tecla META, \n Nenhum log de depuração disponível. Ative as opções de depuração primeiro e inicie um jogo. Falha ao capturar logs: %1$s Registrar erros, avisos e falhas do processo do WinNative em arquivo + Log de Depuração Filtrado + Salvar os logs do aplicativo em um arquivo, filtrados de acordo com o que está configurado no filtro de tags. Emulação Subsistemas Ferramentas diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a533be02e..181d6960d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1101,6 +1101,8 @@ Por ex. META para tecla META, \n Não há registos de depuração disponíveis. Ative primeiro as opções de depuração e inicie um jogo. Falha ao capturar os registos: %1$s Registar erros, avisos e falhas do processo do WinNative num ficheiro + Log de Depuração Filtrado + Salvar os logs do aplicativo em um arquivo, filtrados de acordo com o que está configurado no filtro de tags. Emulação Subsistemas Ferramentas diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 8adc8d20c..92dd23aec 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -816,6 +816,8 @@ De ex. META pentru tasta META, \n Nu sunt disponibile jurnale de depanare. Activează mai întâi opțiunile de depanare și lansează un joc. Capturarea jurnalelor a eșuat: %1$s Înregistrează erorile, avertismentele și blocările procesului WinNative în fișier + Jurnal de depanare filtrat + Salvaţi jurnalele aplicaţiei într-un fişier, filtrate conform configurării din filtrul de etichete. Emulare Subsisteme Instrumente diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5d471159d..48ae4d9c5 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -884,6 +884,8 @@ Журналы отладки отсутствуют. Сначала включите параметры отладки и запустите игру. Не удалось записать журналы: %1$s Записывать ошибки процесса WinNative, предупреждения и сбои в файл. + Отфильтрованный журнал отладки + Сохранить журналы приложения в файл, отфильтрованные согласно настройкам фильтра тегов. Эмуляция Подсистемы Инструменты diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 68af9a13a..a024fc887 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1101,6 +1101,8 @@ T.ex. META för META-tangent, \n Inga felsökningsloggar tillgängliga. Aktivera felsökningsalternativ först och starta ett spel. Det gick inte att fånga loggar: %1$s Logga WinNative-processfel, varningar och krascher till fil + Filtrerad felsökningslogg + Spara appens loggar till en fil, filtrerade enligt vad som är konfigurerat i taggfiltren. Emulering Delsystem Verktyg diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 921e54ac7..d7edee0c0 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1101,6 +1101,8 @@ ไม่มีบันทึกดีบัก เปิดใช้ตัวเลือกการดีบักก่อนแล้วเปิดเกม ไม่สามารถบันทึกได้: %1$s บันทึกข้อผิดพลาด คำเตือน และการขัดข้องของกระบวนการ WinNative ลงไฟล์ + บันทึกดีบั๊กที่กรองแล้ว + บันทึกบันทึกของแอปลงในไฟล์ โดยกรองตามการตั้งค่าในตัวกรองแท็ก การจำลอง ระบบย่อย เครื่องมือ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index b144bebe3..1301695cf 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1101,6 +1101,8 @@ E.g. META için META tuşu, \n Kullanılabilir hata ayıklama günlüğü yok. Önce hata ayıklama seçeneklerini etkinleştirin ve bir oyun başlatın. Günlükler yakalanamadı: %1$s WinNative işlem hatalarını, uyarılarını ve çökmelerini dosyaya kaydet + Filtrelenmiş Hata Ayıklama Günlüğü + Uygulamanın günlüklerini, etiket filtresinde ayarlananlara göre filtreleyerek bir dosyaya kaydet. Öykünme Alt Sistemler Araçlar diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 2bb68296a..9673ab9bf 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -822,6 +822,8 @@ Немає доступних журналів налагодження. Спочатку увімкніть параметри налагодження та запустіть гру. Не вдалося захопити журнали: %1$s Записувати помилки, попередження та збої процесу WinNative у файл + Відфільтрований журнал налагодження + Зберегти журнали застосунка у файл, відфільтровані згідно налаштувань фільтра тегів. Емуляція Підсистеми Інструменти diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0a8cf2f64..38ebe8678 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -816,6 +816,8 @@ 没有可用的调试日志。请先启用调试选项并启动游戏。 无法捕获日志:%1$s 将 WinNative 进程错误、警告和崩溃记录到文件 + 已筛选调试日志 + 将应用的日志保存到文件中,按标签过滤器的配置进行过滤。 模拟 子系统 工具 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2d7a7bea7..a46156580 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -816,6 +816,8 @@ 沒有可用的除錯日誌。請先啟用除錯選項並啟動遊戲。 無法擷取日誌:%1$s 將 WinNative 程序錯誤、警告與當機記錄到檔案 + 已篩選的除錯日誌 + 將應用程式的日誌儲存至檔案,並依標籤過濾器的設定進行篩選。 模擬 子系統 工具 From 22df89a48f8c27492f9f0eb0624550a8db604ff6 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 18 Jul 2026 14:07:04 +0200 Subject: [PATCH 44/62] Fixed erros after merge with main. --- .../stores/steam/service/SteamService.kt | 2 +- .../steam/service/SteamServiceConnection.kt | 37 ++++++++++++++++--- 2 files changed, 32 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 67633a440..28a83b68a 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -243,7 +243,7 @@ class SteamService : Service() { // The current shared family group the logged in user is joined to. // Edited to allow one thread to clear/modify it while others are reading it without crashing. - private val familyGroupMembers = java.util.concurrent.CopyOnWriteArrayList() + internal val familyGroupMembers = java.util.concurrent.CopyOnWriteArrayList() private val appTokens: ConcurrentHashMap = ConcurrentHashMap() diff --git a/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt b/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt index 83b21b4a5..2471a3fb5 100644 --- a/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt +++ b/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt @@ -241,13 +241,25 @@ internal fun SteamService.handleAppForegrounded() { // Cancel any pending suspend timer — the app is back, so the session must stay up regardless of how long it was minimized. backgroundIdleJob?.cancel() backgroundIdleJob = null + /** 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) { + /*if (isRunning && !isStopping) { runCatching { startForeground(1, notificationHelper.createForegroundNotification("Steam Service is running")) 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 Timber.i("App foregrounded — waking the WN-Steam-Client session") @@ -264,7 +276,13 @@ internal fun SteamService.handleAppForegrounded() { /** App went to the background — arm the deferred suspend check. */ internal fun SteamService.handleAppBackgrounded() { appInForeground = false - if (PrefManager.chatStayRunningOnExit && isRunning && !isStopping) { + /** 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( NotificationHelper.BACKGROUND_RUNNING_NOTIFICATION_ID, @@ -272,7 +290,8 @@ internal fun SteamService.handleAppBackgrounded() { ) notificationHelper.cancel() }.onFailure { Timber.w(it, "Failed to show Steam background-chat notification") } - } + }*/ + // ToDo end. scheduleBackgroundSuspendCheck() } @@ -322,12 +341,17 @@ internal fun SteamService.maybeSuspendForBackground(): Boolean { picsGetProductInfoJob?.cancel() messagePollerJob?.cancel() wnSession?.let { s -> runCatching { s.disconnect() } } - scope.launch(Dispatchers.Main) { + // 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") } runCatching { notificationHelper.cancel() } .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 } @@ -510,6 +534,7 @@ internal fun SteamService.clearValues() { _unifiedFriends?.close() _unifiedFriends = null + familyGroupMembers.clear() isStopping = false retryAttempt = 0 From ecde94a5b85876aaf875577f4e28b49f30cc194e Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 18 Jul 2026 20:36:52 +0200 Subject: [PATCH 45/62] More fixes for Event Watch logger. - Fixed a bug where Event Watch could generate an empty log file if a specific file (buffer) size was not reached. - Fixed a bug where the text filter used with the `Include` filter with no tags selected caused a failure in logcat, returning an unintended line. --- app/src/main/runtime/system/LogManager.kt | 60 ++++++++++++++++------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 349a67a6c..715da5758 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -585,16 +585,17 @@ object LogManager { // " messages, which is the signal of the OS killing a process. @JvmStatic - fun startEventWatch(context: Context, label: String = "watcher") { + fun startEventWatch(context: Context, label: String = "undefined-watcher") { if (!cachedEventWatchEnabled) return // Verify READ_LOGS permission at runtime 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" } + if (!hasReadLogs) logI(TAG, context) { "READ_LOGS permission not granted, pause watch may not capture system logs" } stopEventWatch() Thread({ + var process: Process? = null try { // Wipe the historical buffer so this file only contains lines from // this pause window onward — not hours of unrelated backlog. @@ -605,28 +606,41 @@ object LogManager { 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) + val command = mutableListOf("logcat", "-v", "threadtime") command.addAll(buildLogcatFilterSpecArgs()) - manualTextFilter?.let { filter -> - command.add("-e") - val safeFilter = try { - java.util.regex.Pattern.compile(filter) - filter - } catch (e: Exception) { - // Portable escape for logcat's regex engine (RE2/libc) - filter.replace(Regex("[\\\\.^$\\[\\]*+?{}()|]"), "\\\\$0") - } - command.add(safeFilter) - command.add("-i") // Ensures logcat match is case-insensitive like baseLog - } - eventWatchProcess = Runtime.getRuntime().exec(command.toTypedArray()) - closeProcessStdin(eventWatchProcess) + val started = ProcessBuilder(command) + .redirectErrorStream(true) // merge stderr so a single reader drains both pipes + .start() + process = started + eventWatchProcess = started + closeProcessStdin(started) // Schedule a timeout to avoid hanging indefinitely if the user forget to disable this log. watchTimeoutHandler.postDelayed(stopWatchTask, EVENT_WATCH_TIMEOUT_MS) + + val textPattern = manualTextFilterPattern // snapshot once for the life of this watch + + started.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> + java.io.BufferedWriter( + java.io.OutputStreamWriter(java.io.FileOutputStream(file, true), Charsets.UTF_8) + ).use { writer -> + reader.forEachLine { line -> + if (textPattern == null || textPattern.containsMatchIn(line)) { + writer.write(line) + writer.newLine() + writer.flush() + } + } + } + } } catch (e: Exception) { - logE(TAG, e, context) { "Failed to start event watch: ${e.message}" } + // stopEventWatch() destroys the process to end the read loop above, + // which normally surfaces here as a routine IOException (e.g. + // "Stream closed"). Only log when this wasn't an intentional stop. + if (process == null || eventWatchProcess === process) { + logE(TAG, e, context) { "Failed to start event watch: ${e.message}" } + } } }, "LogManager-EventWatch").start() } @@ -637,7 +651,15 @@ object LogManager { watchTimeoutHandler.removeCallbacks(stopWatchTask) try { - eventWatchProcess?.let(::destroyProcess) + // Only close stdin and destroy the process here. The capture thread in + // startEventWatch() owns and actively reads process.inputStream — + // closing it from this thread too would race with that read. + // Destroying the process closes its end of the pipe instead, which + // unblocks the capture thread's read with a clean EOF. + eventWatchProcess?.let { process -> + closeProcessStdin(process) + process.destroy() + } eventWatchProcess = null } catch (e: Exception) { logE(TAG,e) { "Failed to stop pause watch: ${e.message}" } From 8f756cd15f5adfe11f03d6588b024bcbe731749f Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 18 Jul 2026 22:32:21 +0200 Subject: [PATCH 46/62] Removed commented-out code related to Steam Friends Chat. - Code related to the background notification (redundant channel) indicating that the Steam chat is active has been removed. --- .../steam/service/SteamServiceConnection.kt | 47 ++----------------- 1 file changed, 4 insertions(+), 43 deletions(-) diff --git a/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt b/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt index 2471a3fb5..fb5bf025a 100644 --- a/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt +++ b/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt @@ -241,25 +241,6 @@ internal fun SteamService.handleAppForegrounded() { // Cancel any pending suspend timer — the app is back, so the session must stay up regardless of how long it was minimized. backgroundIdleJob?.cancel() backgroundIdleJob = null - /** 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 { - startForeground(1, notificationHelper.createForegroundNotification("Steam Service is running")) - 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 Timber.i("App foregrounded — waking the WN-Steam-Client session") @@ -276,22 +257,6 @@ internal fun SteamService.handleAppForegrounded() { /** App went to the background — arm the deferred suspend check. */ internal fun SteamService.handleAppBackgrounded() { appInForeground = false - /** 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( - NotificationHelper.BACKGROUND_RUNNING_NOTIFICATION_ID, - notificationHelper.createBackgroundRunningNotification(), - ) - notificationHelper.cancel() - }.onFailure { Timber.w(it, "Failed to show Steam background-chat notification") } - }*/ - // ToDo end. scheduleBackgroundSuspendCheck() } @@ -341,14 +306,10 @@ internal fun SteamService.maybeSuspendForBackground(): Boolean { picsGetProductInfoJob?.cancel() messagePollerJob?.cancel() wnSession?.let { s -> runCatching { s.disconnect() } } - // 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") } - runCatching { notificationHelper.cancel() } - .onFailure { Timber.w(it, "Failed to cancel SteamService notification on background suspend") } - }*/ - // ToDo end. + /** ToDo: To ensure the following code works correctly, it is necessary to investigate why, + * when SteamService is stopped in the background without an active game session, SteamService + * resumes after 5–10 seconds. This behavior can be verified in the active services notification. + */ // 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") }*/ From 1f089655dda2654513af1080f5eee9ced806682b Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 18 Jul 2026 23:06:51 +0200 Subject: [PATCH 47/62] Added a suggestion to improve the current Event Watch. But for another PR. --- app/src/main/runtime/system/LogManager.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 715da5758..08b700d82 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -573,17 +573,23 @@ object LogManager { } } - // ── 2. Pause/resume window capture ─────────────────────────────── + // ── Event Watch - window capture ─────────────────────────────── // // Brackets exactly the period you care about: screen-lock to - // screen-unlock. Without android.permission.READ_LOGS granted via + // screen-unlock or app backgrounded. 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): + // system lines like ActivityManager's "Killing (adj N): // " messages, which is the signal of the OS killing a process. + /** + * ToDo: This method can be improved so that it can handle multiple events (calls) that the user can selectively manage, similar to how tags work. + * The best approach would be to use an enum to define what kind of event has been set, replacing the string parameter with the enum (more efficient) + * and adding new enum values as new events are introduced. In this way, it would be possible to select in the UI exactly which specific event + * you want to observe. Examples: ContainerBackground, SteamDownloads, Recording, etc. + */ @JvmStatic fun startEventWatch(context: Context, label: String = "undefined-watcher") { if (!cachedEventWatchEnabled) return From 0dd82d29dd77264b971e48983c00b402c3c82e0b Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 18 Jul 2026 23:46:10 +0200 Subject: [PATCH 48/62] A new ToDo comment has been added that would affect the entire app. It is therefore left as a ToDo and has not been implemented, since a large number of files in the app would need to be modified, and it would be necessary to verify whether performance is affected. --- app/src/main/runtime/system/LogManager.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 08b700d82..2274e9f98 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -486,6 +486,8 @@ object LogManager { // guard it with `if (LogManager.isDebugEnabled)`, or use the lambda // overload below from Kotlin. + // ToDo: For the TAG filters to work across the entire app, the following methods must be used to replace Timber or Log lines. + @JvmStatic @JvmOverloads fun log(tag: String, message: String, context: Context? = null) = baseLog(Level.DEBUG, tag, message, null, context) From bdf34e0b3252d0165f824999802e035a3a77f948 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sun, 19 Jul 2026 18:21:12 +0200 Subject: [PATCH 49/62] Fixed controller navigation and improved DebugScreen appearance. - Fixed the controller not being able to select CollapsibleGroup() elements at all. - Fixed controller focus briefly jumping between elements when expanding/collapsing groups. - Improved the visual appearance of the groups matching the other elements. --- .../feature/settings/debug/DebugScreen.kt | 60 ++++++++++++++----- .../settings/other/OtherSettingsScreen.kt | 4 +- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index 345d65651..a5f3aa429 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -295,6 +295,11 @@ fun DebugScreen( val contentNav = rememberSettingsContentNav(bridge) + // Enable stable cursor to prevent focus jumping during layout shifts (like collapsing groups) + androidx.compose.runtime.SideEffect { + contentNav.stableCursor = true + } + CompositionLocalProvider(LocalPaneNav provides contentNav) { Column( modifier = @@ -324,7 +329,7 @@ fun DebugScreen( // Exit group CollapsibleGroup( title = stringResource(R.string.common_ui_exit), // add string resource "Exit" - accentColor = SystemTagColor, + accentColor = TextSecondary, initiallyExpanded = state.exitReasonLog || state.crashLog, ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // This log only works on API 30+ (Android 11+) @@ -351,7 +356,7 @@ fun DebugScreen( // Filter group CollapsibleGroup( title = stringResource(R.string.session_gyroscope_filtering), // add string resource "Filter" - accentColor = Warning, + accentColor = TextSecondary, initiallyExpanded = state.filteredLogs || state.eventWatchLog, ) { SettingsToggleCard( @@ -2366,28 +2371,50 @@ private fun CollapsibleGroup( Card( modifier = Modifier .fillMaxWidth() - .animateContentSize(), - colors = CardDefaults.cardColors(containerColor = CardDark) + .border(1.dp, CardBorder, RoundedCornerShape(12.dp)), + colors = CardDefaults.cardColors(containerColor = CardDark), + shape = RoundedCornerShape(12.dp) ) { - Column(modifier = Modifier.padding(12.dp)) { + Column(modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickable { expanded = !expanded } + .clip(RoundedCornerShape(10.dp)) + .paneNavItem( + cornerRadius = 10.dp, + onActivate = { expanded = !expanded }, + highlightColor = NavHighlight, + tapToSelect = true + ) + .padding(vertical = 4.dp, horizontal = 4.dp), ) { Column(modifier = Modifier.weight(1f)) { - Text(text = title, style = MaterialTheme.typography.titleMedium, color = TextSecondary) + Text( + text = title.uppercase(), + color = accentColor, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp + ) if (subtitle != null) { - Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + fontSize = 11.sp + ) } } - IconButton(onClick = { expanded = !expanded }) { - Icon( - imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (expanded) "Collapse" else "Expand" - ) - } + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(20.dp) + ) } AnimatedVisibility( @@ -2395,7 +2422,10 @@ private fun CollapsibleGroup( enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { - Column(modifier = Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column( + modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { content() } } diff --git a/app/src/main/feature/settings/other/OtherSettingsScreen.kt b/app/src/main/feature/settings/other/OtherSettingsScreen.kt index 3707861c7..c86ce1f26 100644 --- a/app/src/main/feature/settings/other/OtherSettingsScreen.kt +++ b/app/src/main/feature/settings/other/OtherSettingsScreen.kt @@ -111,7 +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) +private val Error = Color(0xFFFF4444) // State data class OtherSettingsState( @@ -1390,7 +1390,7 @@ private fun HeartbeatFrequencyCard( { Text( text = label, - color = if (isError) Warning else TextSecondary, + color = if (isError) Error else TextSecondary, fontSize = 11.sp, ) } From a0715d963247bc7f3bbda3577e260e2e17cade46 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sun, 19 Jul 2026 19:23:37 +0200 Subject: [PATCH 50/62] Converted collapsible groups (Debug Screen) into controlled components with persisted expansion states. - Now the groups stay as the user left them when returning to the menu. --- .../feature/settings/debug/DebugFragment.kt | 11 +++++++++++ .../main/feature/settings/debug/DebugScreen.kt | 18 ++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index 83bd0ba0f..ba9d8bb1e 100644 --- a/app/src/main/feature/settings/debug/DebugFragment.kt +++ b/app/src/main/feature/settings/debug/DebugFragment.kt @@ -129,6 +129,14 @@ class DebugFragment : Fragment() { onRemoveCustomTag = { tag -> LogManager.removeCustomTag(requireContext(), tag); refresh() }, onManualTextFilterChanged = { text -> LogManager.setManualTextFilter(text) }, // no persistence, no refreshState needed allLogTagOptions = remember(debugState.customTags) { LogManager.getAllKnownTags() }, + onExitGroupExpandedChanged = { expanded -> + preferences.edit { putBoolean("debug_group_exit_expanded", expanded) } + refresh() + }, + onFilterGroupExpandedChanged = { expanded -> + preferences.edit { putBoolean("debug_group_filter_expanded", expanded) } + refresh() + }, onWineDebugChanged = { checked -> preferences.edit { putBoolean("enable_wine_debug", checked) } com.winlator.cmod.runtime.system.LogManager @@ -258,6 +266,9 @@ class DebugFragment : Fragment() { selectedTags = LogManager.getSelectedTags().toList(), customTags = LogManager.getCachedCustomTags(), + exitGroupExpanded = preferences.getBoolean("debug_group_exit_expanded", false), + filterGroupExpanded = preferences.getBoolean("debug_group_filter_expanded", false), + wineDebug = preferences.getBoolean("enable_wine_debug", false), wineChannels = channels, wineClasses = classes, diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index a5f3aa429..b31ae3f42 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -179,6 +179,9 @@ data class DebugState( val selectedTags: List = emptyList(), val customTags: List = emptyList(), + val exitGroupExpanded: Boolean = false, + val filterGroupExpanded: Boolean = false, + val wineDebug: Boolean = false, val wineChannels: List = emptyList(), val wineClasses: List = emptyList(), @@ -217,6 +220,8 @@ fun DebugScreen( onAddCustomTag: (String) -> Unit, onRemoveCustomTag: (String) -> Unit, onManualTextFilterChanged: (String) -> Unit, // not persisted — live field only + onExitGroupExpandedChanged: (Boolean) -> Unit, + onFilterGroupExpandedChanged: (Boolean) -> Unit, onWineDebugChanged: (Boolean) -> Unit, onWineChannelsChanged: (List) -> Unit, onWineClassesChanged: (List) -> Unit, @@ -330,7 +335,8 @@ fun DebugScreen( CollapsibleGroup( title = stringResource(R.string.common_ui_exit), // add string resource "Exit" accentColor = TextSecondary, - initiallyExpanded = state.exitReasonLog || state.crashLog, + expanded = state.exitGroupExpanded, + onExpandedChange = onExitGroupExpandedChanged, ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // This log only works on API 30+ (Android 11+) SettingsToggleCard( @@ -357,7 +363,8 @@ fun DebugScreen( CollapsibleGroup( title = stringResource(R.string.session_gyroscope_filtering), // add string resource "Filter" accentColor = TextSecondary, - initiallyExpanded = state.filteredLogs || state.eventWatchLog, + expanded = state.filterGroupExpanded, + onExpandedChange = onFilterGroupExpandedChanged, ) { SettingsToggleCard( title = stringResource(R.string.settings_filtered_logs_to_file_title), @@ -2363,11 +2370,10 @@ private fun CollapsibleGroup( title: String, subtitle: String? = null, accentColor: Color = Accent, - initiallyExpanded: Boolean = false, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, content: @Composable () -> Unit, ) { - var expanded by remember { mutableStateOf(initiallyExpanded) } - Card( modifier = Modifier .fillMaxWidth() @@ -2386,7 +2392,7 @@ private fun CollapsibleGroup( .clip(RoundedCornerShape(10.dp)) .paneNavItem( cornerRadius = 10.dp, - onActivate = { expanded = !expanded }, + onActivate = { onExpandedChange(!expanded) }, highlightColor = NavHighlight, tapToSelect = true ) From 1f6e9fa7a50fcae610873e1c7a8313e4e37c3ca4 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Mon, 20 Jul 2026 21:47:12 +0200 Subject: [PATCH 51/62] A few more fixes and edits to comments. --- app/src/main/runtime/system/LogManager.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 2274e9f98..d82664e61 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -109,7 +109,7 @@ object LogManager { var cachedFilteredLogEnabled = false @Volatile private var cachedExitReasonLogEnabled = false @Volatile private var cachedCrashLogEnabled = false - @Volatile private var cachedEventWatchEnabled = false + @Volatile var cachedEventWatchEnabled = false @Volatile private var cachedTagFilterMode = TagFilterMode.ALL @Volatile private var cachedSelectedTags: Set = emptySet() @Volatile private var cachedCustomTags: Set = emptySet() @@ -128,7 +128,7 @@ object LogManager { /** Cheap, public, and the recommended guard for any genuinely expensive log message. */ @JvmStatic - val isDebugEnabled: Boolean get() = cachedAppDebugEnabled || cachedFilteredLogEnabled + val isDebugEnabled: Boolean get() = cachedAppDebugEnabled || cachedFilteredLogEnabled || cachedEventWatchEnabled @JvmStatic val isEventWatchEnabled: Boolean get() = cachedEventWatchEnabled @@ -137,7 +137,7 @@ object LogManager { private fun resolveContext(context: Context?): Context? = context?.applicationContext ?: appContext /** - * Call once, ideally from UnifiedActivity.onCreate(), so every later call + * 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. @@ -473,7 +473,7 @@ object LogManager { @JvmStatic fun deleteShareableLogs(context: Context): Int = getShareableLogFiles(context).count { it.delete() } - // ── 1. Custom breadcrumbs, callable from anywhere ─────────────── + // ── 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 @@ -512,22 +512,22 @@ object LogManager { * instead. */ inline fun log(tag: String, context: Context? = null, message: () -> String) { - if (!cachedAppDebugEnabled || !cachedFilteredLogEnabled) return + if (!cachedAppDebugEnabled && !cachedFilteredLogEnabled && !cachedEventWatchEnabled) return baseLog(Level.DEBUG, tag, message(), null, context) } inline fun logI(tag: String, context: Context? = null, message: () -> String) { - if (!cachedAppDebugEnabled || !cachedFilteredLogEnabled) return + if (!cachedAppDebugEnabled && !cachedFilteredLogEnabled && !cachedEventWatchEnabled) return baseLog(Level.INFO, tag, message(), null, context) } inline fun logW(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) { - if (!cachedAppDebugEnabled || !cachedFilteredLogEnabled) return + if (!cachedAppDebugEnabled && !cachedFilteredLogEnabled && !cachedEventWatchEnabled) return baseLog(Level.WARN, tag, message(), t, context) } inline fun logE(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) { - if (!cachedAppDebugEnabled || !cachedFilteredLogEnabled) return + if (!cachedAppDebugEnabled && !cachedFilteredLogEnabled && !cachedEventWatchEnabled) return baseLog(Level.ERROR, tag, message(), t, context) } @@ -708,7 +708,7 @@ object LogManager { return spec } - // ── 3. Exit/killed reasons | crash trace ────────────────────────── + // ── 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 From 5d9434320ccd858485d99658b92c6ebdb0982a21 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Tue, 21 Jul 2026 19:46:44 +0200 Subject: [PATCH 52/62] Another cleaning. - Moved a variable from the bottom of LogManager to the variables section. - Removed a commented-out line referencing a method call that has already been deleted. --- app/src/main/runtime/system/LogManager.kt | 28 +++++++++---------- .../system/SessionKeepAliveService.java | 1 - 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index d82664e61..b0aa5818f 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -136,6 +136,20 @@ object LogManager { private fun resolveContext(context: Context?): Context? = context?.applicationContext ?: appContext + /** + * Exit reasons that constitute an abnormal termination and trigger a + * post-mortem report. Add entries here to cover new cases to be auto-reported. + */ + @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,*/ + ) + /** * Call once, ideally from PluviaApp.onCreate(), so every later call * site — including ones with no Context of their own — has a fallback, @@ -1032,18 +1046,4 @@ object LogManager { else -> "" } } - - /** - * Exit reasons that constitute an abnormal termination and trigger a - * post-mortem report. Add entries here to cover new cases to be auto-reported. - */ - @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,*/ - ) } diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 3207dc99f..0a74749ee 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -137,7 +137,6 @@ public static void onPauseSession(Context ctx) { LogManager.log(TAG, "onPauseSession", ctx); if (instance != null) { instance.acquireWakeLock(); -// instance.runOomSweep(); instance.startHeartbeat(); } updateForegroundState(ctx); From c46771325c6d374ae1947911b656d05e435b5e51 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Fri, 24 Jul 2026 17:36:14 +0200 Subject: [PATCH 53/62] Enable background protection by default on Android 13+ The threshold for enabling the background protection preference by default has been lowered from Android 14 to Android 13. --- app/src/main/feature/setup/SetupWizardActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/feature/setup/SetupWizardActivity.kt b/app/src/main/feature/setup/SetupWizardActivity.kt index e46193eef..e0197a7e0 100644 --- a/app/src/main/feature/setup/SetupWizardActivity.kt +++ b/app/src/main/feature/setup/SetupWizardActivity.kt @@ -894,7 +894,7 @@ 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+. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Enable background protection by default on Android 13+. if (!androidx.preference.PreferenceManager.getDefaultSharedPreferences(this).contains("enable_background_session")) { androidx.preference.PreferenceManager.getDefaultSharedPreferences(this) .edit { putBoolean("enable_background_session", true) } From 9ea48b22ffec029a18ec38fb58f35567d164497c Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sat, 25 Jul 2026 19:04:51 +0200 Subject: [PATCH 54/62] Nick patch: Hide all application log options behind a toggle. - Other changes from the patch: - Improved the Text filter inputbox appearance. - My changes to the patch: - Replaced the if that hid the options with an animation that shows and hides them. - Fixed the master toggle so it no longer also activates the Application Log toggle. It still keeps the behavior of disabling all the other toggles when this one is turned off. --- .../feature/settings/debug/DebugFragment.kt | 19 ++ .../feature/settings/debug/DebugScreen.kt | 301 +++++++++++------- app/src/main/res/values-b+es+419/strings.xml | 3 + app/src/main/res/values-da/strings.xml | 3 + app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 3 + app/src/main/res/values-fi/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-hi/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-no/strings.xml | 3 + app/src/main/res/values-pl/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-pt/strings.xml | 3 + app/src/main/res/values-ro/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-sv/strings.xml | 3 + app/src/main/res/values-th/strings.xml | 3 + app/src/main/res/values-tr/strings.xml | 3 + app/src/main/res/values-uk/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 25 files changed, 280 insertions(+), 109 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index ba9d8bb1e..0bef8ce52 100644 --- a/app/src/main/feature/settings/debug/DebugFragment.kt +++ b/app/src/main/feature/settings/debug/DebugFragment.kt @@ -73,6 +73,23 @@ class DebugFragment : Fragment() { state = debugState, wineChannelOptions = wineChannelOptions, wineClassOptions = SettingsConfig.WINE_DEBUG_CLASSES, + onLoggingEnabledChanged = { checked -> + preferences.edit { + putBoolean("enable_application_logging", checked) + if (!checked) { + putBoolean("enable_app_debug", false) + putBoolean("enable_exit_reason_log", false) + putBoolean("enable_crash_log", false) + putBoolean("enable_filtered_logs", false) + putBoolean("enable_event_watch_log", false) + } + } + refresh() + }, + onLogcatGroupExpandedChanged = { expanded -> + preferences.edit { putBoolean("debug_group_logcat_expanded", expanded) } + refresh() + }, onAppDebugChanged = { checked -> preferences.edit { putBoolean("enable_app_debug", checked) } com.winlator.cmod.runtime.system.ApplicationLogGate @@ -257,6 +274,7 @@ class DebugFragment : Fragment() { ?.filter { it.isNotBlank() } ?: emptyList() debugState = DebugState( + loggingEnabled = preferences.getBoolean("enable_application_logging", false), appDebug = preferences.getBoolean("enable_app_debug", false), filteredLogs = preferences.getBoolean("enable_filtered_logs", false), exitReasonLog = preferences.getBoolean("enable_exit_reason_log", false), @@ -266,6 +284,7 @@ class DebugFragment : Fragment() { selectedTags = LogManager.getSelectedTags().toList(), customTags = LogManager.getCachedCustomTags(), + logcatGroupExpanded = preferences.getBoolean("debug_group_logcat_expanded", false), exitGroupExpanded = preferences.getBoolean("debug_group_exit_expanded", false), filterGroupExpanded = preferences.getBoolean("debug_group_filter_expanded", false), diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index b31ae3f42..52a51351c 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -135,7 +135,6 @@ 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.layout.PaddingValues import androidx.compose.foundation.text.KeyboardActions @@ -148,6 +147,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction @@ -170,6 +170,7 @@ private val SystemTagColor = Color(0xFFFFCC00) // State data class DebugState( + val loggingEnabled: Boolean = false, val appDebug: Boolean = false, val filteredLogs: Boolean = false, val exitReasonLog: Boolean = false, @@ -179,6 +180,7 @@ data class DebugState( val selectedTags: List = emptyList(), val customTags: List = emptyList(), + val logcatGroupExpanded: Boolean = false, val exitGroupExpanded: Boolean = false, val filterGroupExpanded: Boolean = false, @@ -210,6 +212,7 @@ fun DebugScreen( wineChannelOptions: List, wineClassOptions: List, allLogTagOptions: List, // LogManager.getAllKnownTags() + onLoggingEnabledChanged: (Boolean) -> Unit, onAppDebugChanged: (Boolean) -> Unit, onFilteredLogsChanged: (Boolean) -> Unit, onExitReasonLogChanged: (Boolean) -> Unit, @@ -220,6 +223,7 @@ fun DebugScreen( onAddCustomTag: (String) -> Unit, onRemoveCustomTag: (String) -> Unit, onManualTextFilterChanged: (String) -> Unit, // not persisted — live field only + onLogcatGroupExpandedChanged: (Boolean) -> Unit, onExitGroupExpandedChanged: (Boolean) -> Unit, onFilterGroupExpandedChanged: (Boolean) -> Unit, onWineDebugChanged: (Boolean) -> Unit, @@ -323,108 +327,153 @@ fun DebugScreen( SectionLabel(stringResource(R.string.common_ui_application)) SettingsToggleCard( - title = stringResource(R.string.common_ui_application), - subtitle = stringResource(R.string.settings_debug_log_to_file_desc), + title = stringResource(R.string.settings_debug_application_logging_title), + subtitle = stringResource(R.string.settings_debug_application_logging_subtitle), icon = Icons.Outlined.BugReport, accentColor = Warning, - checked = state.appDebug, - onCheckedChange = onAppDebugChanged, + checked = state.loggingEnabled, + onCheckedChange = onLoggingEnabledChanged, ) - // Exit group - CollapsibleGroup( - title = stringResource(R.string.common_ui_exit), // add string resource "Exit" - accentColor = TextSecondary, - expanded = state.exitGroupExpanded, - onExpandedChange = onExitGroupExpandedChanged, + AnimatedVisibility( + visible = state.loggingEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), ) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // This log only works on API 30+ (Android 11+) + // Logcat group + CollapsibleGroup( + title = stringResource(R.string.settings_debug_logcat_title), + accentColor = TextSecondary, + expanded = state.logcatGroupExpanded, + onExpandedChange = onLogcatGroupExpandedChanged, + ) { SettingsToggleCard( - title = stringResource(R.string.settings_debug_exit_reason_log_title), - subtitle = stringResource(R.string.settings_debug_exit_reason_log_subtitle), + title = stringResource(R.string.settings_debug_application_logging_title), + subtitle = stringResource(R.string.settings_debug_log_to_file_desc), icon = Icons.Outlined.BugReport, - accentColor = SystemTagColor, - checked = state.exitReasonLog, - onCheckedChange = onExitReasonLogChanged, + accentColor = Warning, + checked = state.appDebug, + onCheckedChange = onAppDebugChanged, ) - } - SettingsToggleCard( - title = stringResource(R.string.settings_debug_crash_log_title), - subtitle = stringResource(R.string.settings_debug_crash_log_subtitle), - icon = Icons.Outlined.BugReport, - accentColor = SystemTagColor, - checked = state.crashLog, - onCheckedChange = onCrashLogChanged, - ) - } + SettingsToggleCard( + title = stringResource(R.string.settings_debug_steam_logs_title), + subtitle = stringResource(R.string.settings_debug_steam_logs_subtitle), + icon = Icons.Outlined.SportsEsports, + checked = state.steamLogs, + onCheckedChange = onSteamLogsChanged, + ) - // Filter group - CollapsibleGroup( - title = stringResource(R.string.session_gyroscope_filtering), // add string resource "Filter" - accentColor = TextSecondary, - expanded = state.filterGroupExpanded, - onExpandedChange = onFilterGroupExpandedChanged, - ) { - SettingsToggleCard( - title = stringResource(R.string.settings_filtered_logs_to_file_title), - subtitle = stringResource(R.string.settings_filtered_logs_to_file_desc), - icon = Icons.Outlined.BugReport, - accentColor = Warning, - checked = state.filteredLogs, - onCheckedChange = onFilteredLogsChanged, - ) + SettingsToggleCard( + title = stringResource(R.string.settings_debug_input_logs), + subtitle = stringResource(R.string.settings_debug_input_logs_description), + icon = Icons.Outlined.Gamepad, + checked = state.inputLogs, + onCheckedChange = onInputLogsChanged, + ) - 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, - accentColor = SystemTagColor, - checked = state.eventWatchLog, - onCheckedChange = onEventWatchLogChanged, - ) + SettingsToggleCard( + title = stringResource(R.string.settings_debug_download_logs), + subtitle = stringResource(R.string.settings_debug_download_logs_description), + icon = Icons.Outlined.CloudDownload, + checked = state.downloadLogs, + onCheckedChange = onDownloadLogsChanged, + ) + } + } - AnimatedVisibility( - visible = state.filteredLogs || state.eventWatchLog, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), + AnimatedVisibility( + visible = state.loggingEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + // Exit group + CollapsibleGroup( + title = stringResource(R.string.common_ui_exit), + accentColor = TextSecondary, + expanded = state.exitGroupExpanded, + onExpandedChange = onExitGroupExpandedChanged, ) { - LogTagFilterCard( - mode = state.tagFilterMode, - selectedTags = state.selectedTags, - enabled = state.filteredLogs || state.eventWatchLog, - onEdit = { showTagFilterDialog = true }, - onModeChanged = onTagFilterModeChanged, - onRemoveSelectedTag = { tag -> - onSelectedTagsChanged(state.selectedTags - tag) - }, + 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, + accentColor = SystemTagColor, + checked = state.exitReasonLog, + onCheckedChange = onExitReasonLogChanged, + ) + } + + SettingsToggleCard( + title = stringResource(R.string.settings_debug_crash_log_title), + subtitle = stringResource(R.string.settings_debug_crash_log_subtitle), + icon = Icons.Outlined.BugReport, + accentColor = SystemTagColor, + checked = state.crashLog, + onCheckedChange = onCrashLogChanged, ) } + } - AnimatedVisibility( - visible = state.filteredLogs || state.eventWatchLog, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), + AnimatedVisibility( + visible = state.loggingEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + // Filter group + CollapsibleGroup( + title = stringResource(R.string.session_gyroscope_filtering), + accentColor = TextSecondary, + expanded = state.filterGroupExpanded, + onExpandedChange = onFilterGroupExpandedChanged, ) { - var manualFilterText by remember { mutableStateOf(LogManager.getManualTextFilter()) } - LaunchedEffect(state.filteredLogs, state.eventWatchLog) { manualFilterText = 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.filteredLogs || state.eventWatchLog, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .focusProperties { canFocus = state.filteredLogs || state.eventWatchLog }, + SettingsToggleCard( + title = stringResource(R.string.settings_filtered_logs_to_file_title), + subtitle = stringResource(R.string.settings_filtered_logs_to_file_desc), + icon = Icons.Outlined.BugReport, + accentColor = Warning, + checked = state.filteredLogs, + onCheckedChange = onFilteredLogsChanged, + ) + + 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, + accentColor = SystemTagColor, + checked = state.eventWatchLog, + onCheckedChange = onEventWatchLogChanged, ) + + AnimatedVisibility( + visible = state.filteredLogs || state.eventWatchLog, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LogTagFilterCard( + mode = state.tagFilterMode, + selectedTags = state.selectedTags, + enabled = state.filteredLogs || state.eventWatchLog, + onEdit = { showTagFilterDialog = true }, + onModeChanged = onTagFilterModeChanged, + onRemoveSelectedTag = { tag -> + onSelectedTagsChanged(state.selectedTags - tag) + }, + ) + } + + AnimatedVisibility( + visible = state.filteredLogs || state.eventWatchLog, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + ManualTextFilterField( + enabled = state.filteredLogs || state.eventWatchLog, + resetKey = state.filteredLogs to state.eventWatchLog, + onTextChanged = onManualTextFilterChanged, + ) + } } } @@ -482,30 +531,6 @@ fun DebugScreen( ) */ - SettingsToggleCard( - title = stringResource(R.string.settings_debug_steam_logs_title), - subtitle = stringResource(R.string.settings_debug_steam_logs_subtitle), - icon = Icons.Outlined.SportsEsports, - checked = state.steamLogs, - onCheckedChange = onSteamLogsChanged, - ) - - SettingsToggleCard( - title = stringResource(R.string.settings_debug_input_logs), - subtitle = stringResource(R.string.settings_debug_input_logs_description), - icon = Icons.Outlined.Gamepad, - checked = state.inputLogs, - onCheckedChange = onInputLogsChanged, - ) - - SettingsToggleCard( - title = stringResource(R.string.settings_debug_download_logs), - subtitle = stringResource(R.string.settings_debug_download_logs_description), - icon = Icons.Outlined.CloudDownload, - checked = state.downloadLogs, - onCheckedChange = onDownloadLogsChanged, - ) - SettingsToggleCard( title = stringResource(R.string.settings_hud_record_to_file_title), subtitle = stringResource(R.string.settings_hud_record_to_file_summary), @@ -550,6 +575,64 @@ private fun SectionLabel( ) } +@Composable +private fun ManualTextFilterField( + enabled: Boolean, + resetKey: Any, + onTextChanged: (String) -> Unit, +) { + var text by remember { mutableStateOf(LogManager.getManualTextFilter()) } + LaunchedEffect(resetKey) { text = LogManager.getManualTextFilter() } + var focused by remember { mutableStateOf(false) } + val focusRequester = remember { androidx.compose.ui.focus.FocusRequester() } + val focusManager = LocalFocusManager.current + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .height(38.dp) + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = if (focused) Accent else CardBorder, + shape = RoundedCornerShape(8.dp), + ).paneNavItem( + cornerRadius = 8.dp, + onActivate = { if (enabled) runCatching { focusRequester.requestFocus() } }, + highlightColor = NavHighlight, + tapToSelect = true, + ).padding(horizontal = 12.dp), + contentAlignment = Alignment.CenterStart, + ) { + if (text.isEmpty()) { + Text( + text = stringResource(R.string.settings_debug_manual_text_filter_hint), + fontSize = 12.sp, + color = TextSecondary, + ) + } + androidx.compose.foundation.text.BasicTextField( + value = text, + onValueChange = { + text = it + onTextChanged(it) + }, + enabled = enabled, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle(color = TextPrimary, fontSize = 13.sp), + cursorBrush = SolidColor(Accent), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { focused = it.isFocused }, + ) + } +} + // Settings toggle card @Composable private fun SettingsToggleCard( 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 8590cf5cf..09ee95673 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1621,6 +1621,9 @@ Ruta instalada: Actualizar + Registro de la aplicación + Activa el registro y muestra las opciones de registro + Logcat Registro de motivo de salida Registra por qué terminó el proceso de la aplicación Registro de fallos diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index f77c2d940..d15e9917b 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1623,6 +1623,9 @@ Installeret sti: Opdater + App-logning + Slå logning til, og vis logindstillingerne + Logcat Log over lukningsårsag Registrerer, hvorfor appens proces sidst blev afsluttet Nedbrudslog diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a847cc081..b2ea0cef8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1570,6 +1570,9 @@ Installierter Pfad: Zum Zielen neigen (Ausrichtung) + Anwendungsprotokollierung + Protokollierung aktivieren und die Protokolloptionen anzeigen + Logcat Grund-für-Beendigung-Protokoll Erfasst, warum der App-Prozess beendet wurde Absturzprotokoll diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e651fa3b4..e67bda99f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1570,6 +1570,9 @@ Ruta instalada: Inclinar para apuntar (orientación) + Registro de la aplicación + Activa el registro y muestra las opciones de registro + Logcat Registro de razón de salida Registra por qué terminó el proceso de la aplicación Registro de fallos diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 71337c7b8..c58716fab 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1621,6 +1621,9 @@ Asennuspolku: Päivitä + Sovelluslokitus + Ota lokitus käyttöön ja näytä lokitusasetukset + Logcat Sulkeutumissyyloki Tallentaa syyn, miksi sovelluksen prosessi viimeksi päättyi Kaatumisloki diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 98d5cbba5..fe5683e9d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1570,6 +1570,9 @@ Chemin installé : Incliner pour viser (orientation) + Journalisation de l\'application + Activer la journalisation et afficher les options de journalisation + Logcat Journal des raisons de sortie Enregistre pourquoi le processus de l\'application s\'est terminé Journal des plantages diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 2831a01e2..3bcf851de 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1558,6 +1558,9 @@ रीफ़्रेश + एप्लिकेशन लॉगिंग + लॉगिंग चालू करें और लॉगिंग विकल्प दिखाएँ + Logcat बंद होने के कारण का लॉग रिकॉर्ड करता है कि ऐप प्रोसेस पिछली बार क्यों बंद हुई क्रैश लॉग diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 23fc2c19b..a00ccd838 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1570,6 +1570,9 @@ Percorso installato: Inclina per mirare (orientamento) + Registro applicazione + Attiva il registro e mostra le opzioni di registro + Logcat Registro motivo uscita Registra il motivo della terminazione del processo dell\'app Registro errori diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 51dfb4ff7..f19e8b830 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1621,6 +1621,9 @@ 更新 + アプリのログ記録 + ログ記録を有効にしてログオプションを表示します + Logcat 終了理由ログ アプリのプロセスが前回終了した理由を記録します クラッシュログ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index a10e2f7bd..f34d957b6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1623,6 +1623,9 @@ 새로고침 + 애플리케이션 로깅 + 로깅을 켜고 로깅 옵션을 표시합니다 + Logcat 종료 원인 로그 앱 프로세스가 마지막으로 종료된 이유를 기록합니다 충돌 로그 diff --git a/app/src/main/res/values-no/strings.xml b/app/src/main/res/values-no/strings.xml index 111217765..f328dd8e9 100644 --- a/app/src/main/res/values-no/strings.xml +++ b/app/src/main/res/values-no/strings.xml @@ -1621,6 +1621,9 @@ Installert bane: Oppdater + Applogging + Slå på logging og vis loggalternativene + Logcat Logg over avslutningsårsak Registrerer hvorfor appens prosess sist ble avsluttet Krasjlogg diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 64d64f0ff..17133df30 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1628,6 +1628,9 @@ Zainstalowana ścieżka: Odśwież + Rejestrowanie aplikacji + Włącz rejestrowanie i pokaż opcje rejestrowania + Logcat Log przyczyny zamknięcia Zapisuje, dlaczego proces aplikacji został ostatnio zakończony Log awarii diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a73956d6f..e2ab0c402 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1622,6 +1622,9 @@ Caminho instalado: Atualizar + Registro do aplicativo + Ativar o registro e mostrar as opções de registro + Logcat Log de motivo de encerramento Registra por que o processo do aplicativo foi encerrado da última vez Log de falhas diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3f94502d5..829fa2800 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1621,6 +1621,9 @@ Caminho instalado: Atualizar + Registo da aplicação + Ativar o registo e mostrar as opções de registo + Logcat Registo do motivo de encerramento Regista porque é que o processo da aplicação terminou da última vez Registo de falhas diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 5d32af575..2f56c2734 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1621,6 +1621,9 @@ Cale instalata: Reîmprospătează + Jurnalizare aplicatie + Activeaza jurnalizarea si afiseaza optiunile de jurnalizare + Logcat Jurnal motiv de închidere Înregistrează de ce s-a încheiat ultima dată procesul aplicației Jurnal de blocări diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 106297b41..bdee3caf2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1528,6 +1528,9 @@ Обновить + Журналирование приложения + Включить журналирование и показать параметры журналирования + Logcat Журнал причин завершения Записывать причину последнего завершения процесса приложения Журнал сбоев diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 533ae693f..2820d641b 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1621,6 +1621,9 @@ Installerad sökväg: Uppdatera + Applikationsloggning + Slå på loggning och visa loggalternativen + Logcat Logg över avslutsorsak Registrerar varför appens process senast avslutades Kraschlogg diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index a5d90fe1c..202fd8729 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1621,6 +1621,9 @@ รีเฟรช + การบันทึกล็อกแอป + เปิดการบันทึกล็อกและแสดงตัวเลือกการบันทึกล็อก + Logcat บันทึกสาเหตุการปิด บันทึกว่าทำไมโปรเซสของแอปจึงถูกปิดครั้งล่าสุด บันทึกการแครช diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 77f50cf7f..ee5f471d4 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1621,6 +1621,9 @@ Yüklü konum: Yenile + Uygulama günlüğü + Günlük kaydını açar ve günlük seçeneklerini gösterir + Logcat Çıkış nedeni günlüğü Uygulama işleminin son kapanma nedenini kaydeder Çökme günlüğü diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c2a394f14..6f69d40e7 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1628,6 +1628,9 @@ Оновити + Журналювання застосунку + Увімкнути журналювання та показати параметри журналювання + Logcat Журнал причин завершення Записувати причину останнього завершення процесу застосунку Журнал збоїв diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4e4578f65..8b19e07ff 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1622,6 +1622,9 @@ 刷新 + 应用日志记录 + 开启日志记录并显示日志选项 + Logcat 退出原因日志 记录应用进程上次终止的原因 崩溃日志 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a79c6ec8a..c80b81944 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1621,6 +1621,9 @@ 重新整理 + 應用程式日誌記錄 + 開啟日誌記錄並顯示日誌選項 + Logcat 結束原因日誌 記錄應用程式程序上次終止的原因 當機日誌 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 755ce497d..b5d08a72a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1139,6 +1139,9 @@ E.g. META for META key, \n Wine Debug Channel Debug + Application logging + Turn on logging and show the logging options + Logcat Exit reason log Record why the app process last terminated Crash log From f0944b1e5b7f3624280bde386feb96703b707859 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Sun, 26 Jul 2026 14:19:42 +0200 Subject: [PATCH 55/62] =?UTF-8?q?Almost=20all=20log=20files=20are=20now=20?= =?UTF-8?q?generated=20and=20saved=20in=20the=20app=E2=80=99s=20internal?= =?UTF-8?q?=20storage.=20-=20Also,=20added=20timestamped=20names=20to=20lo?= =?UTF-8?q?g=20files,=20by=20Nick.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/settings/debug/DebugFragment.kt | 22 +++ app/src/main/runtime/system/LogFileUtils.java | 2 +- app/src/main/runtime/system/LogManager.kt | 126 +++++++++++++----- .../main/runtime/system/SessionLogWriter.java | 2 +- 4 files changed, 115 insertions(+), 37 deletions(-) diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index 0bef8ce52..928b42337 100644 --- a/app/src/main/feature/settings/debug/DebugFragment.kt +++ b/app/src/main/feature/settings/debug/DebugFragment.kt @@ -78,12 +78,20 @@ class DebugFragment : Fragment() { putBoolean("enable_application_logging", checked) if (!checked) { putBoolean("enable_app_debug", false) + putBoolean("enable_input_logs", false) + putBoolean("enable_download_logs", false) putBoolean("enable_exit_reason_log", false) putBoolean("enable_crash_log", false) putBoolean("enable_filtered_logs", false) putBoolean("enable_event_watch_log", false) } } + if (!checked) { + com.winlator.cmod.feature.stores.steam.utils.PrefManager.enableSteamLogs = false + com.winlator.cmod.runtime.system.ApplicationLogGate + .setEnabled(false) + } + refresh() }, onLogcatGroupExpandedChanged = { expanded -> @@ -196,18 +204,21 @@ class DebugFragment : Fragment() { ) { timber.log.Timber.plant(timber.log.Timber.DebugTree()) } + if (checked) ensureAppLoggingOn() com.winlator.cmod.runtime.system.LogManager .updateLoggingState(ctx) refresh() }, onInputLogsChanged = { checked -> preferences.edit { putBoolean("enable_input_logs", checked) } + if (checked) ensureAppLoggingOn() com.winlator.cmod.runtime.system.LogManager .updateLoggingState(ctx) refresh() }, onDownloadLogsChanged = { checked -> preferences.edit { putBoolean("enable_download_logs", checked) } + if (checked) ensureAppLoggingOn() com.winlator.cmod.runtime.system.LogManager .updateLoggingState(ctx) refresh() @@ -537,6 +548,17 @@ class DebugFragment : Fragment() { refresh() } + // Steam/input/download logs are only flags on log statements that land in the application + // log capture, so they produce nothing unless application logging is running. + private fun ensureAppLoggingOn() { + if (preferences.getBoolean("enable_app_debug", false)) return + preferences.edit { putBoolean("enable_app_debug", true) } + com.winlator.cmod.runtime.system.ApplicationLogGate + .setEnabled(true) + com.winlator.cmod.runtime.system.LogManager + .startAppLogging(requireContext(), reset = false) + } + companion object { private const val KEY_DOWNLOADED_LOGS = "downloaded_log_keys" diff --git a/app/src/main/runtime/system/LogFileUtils.java b/app/src/main/runtime/system/LogFileUtils.java index 93ac4b9cc..53807fa2a 100644 --- a/app/src/main/runtime/system/LogFileUtils.java +++ b/app/src/main/runtime/system/LogFileUtils.java @@ -15,7 +15,7 @@ public static void setFilename(String file) { } public static File getLogFile(Context context) { - File logsDir = LogManager.getLogsDir(context); + File logsDir = LogManager.getLogsDir(context, false); String logFile = fileName.replaceAll("[^a-zA-Z0-9\\-_]", "_").toLowerCase() + "_" diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index b0aa5818f..96c0d033f 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -113,11 +113,13 @@ object LogManager { @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 manualTextFilter: String? = null @Volatile private var manualTextFilterPattern: Regex? = null + @Volatile private var cachedPrivateLogsDir: File? = null + @Volatile private var cachedExternalLogsDir: File? = null + private var prefsListener: SharedPreferences.OnSharedPreferenceChangeListener? = null private val RELEVANT_KEYS = setOf( @@ -136,6 +138,13 @@ object LogManager { private fun resolveContext(context: Context?): Context? = context?.applicationContext ?: appContext + // Every log file carries the date/time it was created, the same way SessionLogWriter names the + // box64/fex/wine logs. Resolved once per process so appends keep landing in the same file. + private val sessionStamp: String by lazy { + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss", Locale.US)) + } + private val stampedNames = java.util.concurrent.ConcurrentHashMap() + /** * Exit reasons that constitute an abnormal termination and trigger a * post-mortem report. Add entries here to cover new cases to be auto-reported. @@ -206,7 +215,8 @@ object LogManager { }.getOrDefault(TagFilterMode.ALL) cachedSelectedTags = splitPref(prefs, PREF_SELECTED_TAGS) cachedCustomTags = splitPref(prefs, PREF_CUSTOM_TAGS) - cachedLogsDir = resolveLogsDir(context, prefs) + cachedPrivateLogsDir = resolveLogsDir(context, prefs) + cachedExternalLogsDir = resolveLogsDir(context, prefs, true) } private fun splitPref(prefs: SharedPreferences, key: String): Set = @@ -217,8 +227,12 @@ object LogManager { // ── Logs directory ─────────────────────────────────────────────── @JvmStatic - fun getLogsDir(context: Context): File { - cachedLogsDir?.let { return it } + fun getLogsDir(context: Context, isExternal: Boolean = false): File { + if (isExternal) + cachedExternalLogsDir?.let { return it } + else + cachedPrivateLogsDir?.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 @@ -226,21 +240,26 @@ object LogManager { 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 + val dir = resolveLogsDir(ctx, PreferenceManager.getDefaultSharedPreferences(ctx), isExternal) + if (isExternal) cachedExternalLogsDir = dir else cachedPrivateLogsDir = dir Timber.tag(TAG).d("Logs dir: $dir") return dir } - private fun resolveLogsDir(context: Context, prefs: SharedPreferences): File { - val currentPath = resolvePathString( - prefs.getString("winlator_path_uri", null), SettingsConfig.DEFAULT_WINLATOR_PATH, context, - ) + private fun resolveLogsDir(context: Context, prefs: SharedPreferences, isExternal: Boolean = false): File { + val currentPath: File = if (isExternal) { + File( + resolvePathString( + prefs.getString("winlator_path_uri", null), + SettingsConfig.DEFAULT_WINLATOR_PATH, + context + ) + ) + } else { + context.getExternalFilesDir(null) ?: context.filesDir + } Timber.d("Winnative pathString: $currentPath") @@ -267,6 +286,10 @@ object LogManager { prefs.getBoolean("enable_steam_logs", false) || prefs.getBoolean("enable_input_logs", false) || prefs.getBoolean("enable_download_logs", false) || + cachedFilteredLogEnabled || + cachedEventWatchEnabled || + cachedExitReasonLogEnabled || + cachedCrashLogEnabled || cachedAppDebugEnabled } @@ -276,6 +299,8 @@ object LogManager { } } + // Container boot only (XServerDisplayActivity), never app startup: a booting session starts + // from clean logs, while a plain cold start keeps whatever the previous runs wrote. @JvmStatic fun prepareForNewSession(context: Context) { stopAppLogging() @@ -284,6 +309,24 @@ object LogManager { startAppLogging(context, reset = true) } + private fun stamped(baseName: String): String { + return stampedNames.getOrPut(baseName) { + val dot = baseName.lastIndexOf('.') + if (dot > 0) { + "${baseName.substring(0, dot)}_$sessionStamp${baseName.substring(dot)}" + } else { + "${baseName}_$sessionStamp.log" + } + } + } + + /** True when any run's copy of [baseName] exists, since the name now carries a timestamp. */ + private fun anyLogExists(logsDir: File, baseName: String): Boolean { + val dot = baseName.lastIndexOf('.') + val prefix = if (dot > 0) baseName.substring(0, dot) else baseName + return logsDir.listFiles()?.any { it.isFile && it.name.startsWith("${prefix}_") } == true + } + // ── Tag management (settings UI surface) ────────────────────────── /** Union of build-time-discovered tags and user-added custom ones, sorted for display. */ @@ -375,7 +418,7 @@ object LogManager { return } - val logFile = File(getLogsDir(context), "logcat.log") + val logFile = File(getLogsDir(context), stamped("logcat.log")) try { stopLogcat() @@ -405,14 +448,16 @@ object LogManager { } fun clearLogs(context: Context) { - getLogsDir(context).listFiles()?.forEach { it.delete() } + // Clean both directories + getLogsDir(context, true).listFiles()?.forEach { it.delete() } + getLogsDir(context, false).listFiles()?.forEach { it.delete() } } @JvmStatic @JvmOverloads fun startAppLogging(context: Context, reset: Boolean = false) { if (!cachedAppDebugEnabled) return - val logFile = File(getLogsDir(context), "application.log") + val logFile = File(getLogsDir(context), stamped("application.log")) try { stopAppLogging() @@ -471,17 +516,23 @@ object LogManager { @JvmStatic fun getShareableLogFiles(context: Context): Array { - val logsDir = getLogsDir(context) - return logsDir - .listFiles() - ?.filter { - it.isFile && (it.name.endsWith(".log") || it.name.endsWith(".txt") || it.name.endsWith(".csv")) - }?.toTypedArray() ?: emptyArray() + val filter: (File) -> Boolean = { + it.isFile && (it.name.endsWith(".log") || it.name.endsWith(".txt") || it.name.endsWith(".csv")) + } + // Aggregate files from both the internal and external directories + val external = getLogsDir(context, true).listFiles()?.filter(filter) ?: emptyList() + val private = getLogsDir(context, false).listFiles()?.filter(filter) ?: emptyList() + return (external + private).toTypedArray() } /** Total bytes of all shareable log files. */ @JvmStatic - fun getShareableLogsSize(context: Context): Long = getShareableLogFiles(context).sumOf { it.length() } + fun getShareableLogsSize(context: Context): Long { + // Sum sizes from both internal and external directories + val internalSize = getLogsDir(context, false).walk().filter { it.isFile }.sumOf { it.length() } + val externalSize = getLogsDir(context, true).walk().filter { it.isFile }.sumOf { it.length() } + return internalSize + externalSize + } /** Deletes all shareable log files; returns the count removed. */ @JvmStatic @@ -576,13 +627,18 @@ object LogManager { val ctx = resolveContext(context) ?: return val fullMessage = if (t != null) "$message :: ${Log.getStackTraceString(t)}" else message - appendLine(ctx, APP_LOG_FILE, "${level.prefix}/$tag", fullMessage) + appendLine(ctx, stamped(APP_LOG_FILE), "${level.prefix}/$tag", fullMessage) } private fun appendLine(context: Context, fileName: String, level: String, message: String) { try { val now = LocalDateTime.now().format(logTimestampFormatter) - File(getLogsDir(context), fileName).appendText("$now $level: $message\n") + + // Define high-priority files that go to External storage + val isExternal = fileName.startsWith(POST_MORTEM_FILE.substringBeforeLast('.')) + + val dir = getLogsDir(context, isExternal) + File(dir, fileName).appendText("$now $level: $message\n") } catch (e: Exception) { // Need to be Log for when baseLog and Timber doesn't work. Log.e(TAG, "Failed to append to $fileName: ${e.message}", e) @@ -748,15 +804,15 @@ object LogManager { // 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_") } + if (!anyLogExists(logsDir, EXIT_REASONS_FILE)) loggedKeys.removeAll { it.startsWith("exit_") } + if (!anyLogExists(logsDir, CRASH_FILE)) loggedKeys.removeAll { it.startsWith("crash_") } var wroteSomething = (loggedKeys.size != originalSize) if (infos.isEmpty()) { // 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 (cachedExitReasonLogEnabled && !anyLogExists(logsDir, EXIT_REASONS_FILE)) { + appendLine(ctx, stamped(EXIT_REASONS_FILE), "I/$TAG", "No historical exit info available") } if (wroteSomething) persistKeys(prefs, PREF_LOGGED_EXIT_KEYS, loggedKeys) return @@ -771,12 +827,12 @@ object LogManager { if (cachedExitReasonLogEnabled && exitKey !in loggedKeys) { // Separator line with reason number: 0 = newest/last, larger = older appendLine( - ctx, EXIT_REASONS_FILE, "I/$TAG", + ctx, stamped(EXIT_REASONS_FILE), "I/$TAG", "\n---- Exit reason #${index} (1=new/last, ${infos.size-1}=oldest) ----" ) appendLine( - ctx, EXIT_REASONS_FILE, "I/$TAG", + ctx, stamped(EXIT_REASONS_FILE), "I/$TAG", "pid=${info.pid} reason=${info.reason}-[${getExitReasonName(info.reason)}] status=${info.status} " + "importance=${info.importance} desc=${info.description} timestamp=${timestamp}", ) @@ -804,7 +860,7 @@ object LogManager { val rawTrace = input.bufferedReader().readText() val summary = extractTraceExcerpt(rawTrace, info.reason, maxFrames = 20) appendLine( - ctx, CRASH_FILE, "I/$TAG", + ctx, stamped(CRASH_FILE), "I/$TAG", "\n=== Historical $type Detected ===\n" + "PID: ${info.pid} | Timestamp: ${timestamp}\n" + "Description: ${info.description}\n" + @@ -813,7 +869,7 @@ object LogManager { ) } ?: 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}") + appendLine(ctx, stamped(CRASH_FILE), "I/$TAG", "Historical $type (No trace available) pid=${info.pid} desc=${info.description}") } } catch (e: Exception) { logE(TAG,e, context) { "Failed to read historical trace: ${e.message}" } @@ -885,7 +941,7 @@ object LogManager { // 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) + val postMortemFile = File(getLogsDir(ctx, true), stamped(POST_MORTEM_FILE)) if (!postMortemFile.exists()) { prefs.edit { remove(PREF_POST_MORTEM_KEYS) } } @@ -903,7 +959,7 @@ object LogManager { val key = "pm_${exitKey(info)}" if (key in loggedKeys) continue - appendLine(ctx, POST_MORTEM_FILE, "I/$TAG", buildPostMortemReport(info, ctx)) + appendLine(ctx, stamped(POST_MORTEM_FILE), "I/$TAG", buildPostMortemReport(info, ctx)) loggedKeys.add(key) wrote = true } diff --git a/app/src/main/runtime/system/SessionLogWriter.java b/app/src/main/runtime/system/SessionLogWriter.java index d8fe4c325..c968126fb 100644 --- a/app/src/main/runtime/system/SessionLogWriter.java +++ b/app/src/main/runtime/system/SessionLogWriter.java @@ -39,7 +39,7 @@ public static SessionLogWriter create( boolean fexActive) { SessionLogWriter writer = new SessionLogWriter(); try { - File logsDir = LogManager.getLogsDir(context); + File logsDir = LogManager.getLogsDir(context, false); String exe = sanitize(executable); String stamp = DateFormat.format("yyyy-MM-dd_HH-mm-ss", new Date()).toString(); From f3fe8b1f03a4fa54c5c3e3849c9fcbb34d2e90d5 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Mon, 27 Jul 2026 20:59:00 +0200 Subject: [PATCH 56/62] Some bug fixes and improvements (7). - Fixed heartbeat thread race conditions and state clobbering by using per-thread signals and synchronized publication. - Fixed FD-reuse race in XConnectorEpoll by removing the redundant client map deletion after closing the file descriptor. - Fixed serviceStopping flag persistence in SessionKeepAliveService to ensure direct foreground updates after service restart. - Fixed the Event Watch running during PiP mode. - Fixed the inconsistency in the container status notification when using PiP mode. - Removed the mode that pauses all processes except the game. - Fixed teardown race and blocking writes in XConnectorEpoll by ensuring all poll threads are joined during shutdown and implementing native socket write timeouts. --- app/src/main/cpp/winlator/xconnector_epoll.c | 5 + .../settings/other/OtherSettingsScreen.kt | 1 - .../display/XServerDisplayActivity.java | 25 ++-- .../display/connector/XConnectorEpoll.java | 120 ++++++++++-------- .../main/runtime/system/ProcessHelper.java | 10 +- .../system/SessionKeepAliveService.java | 100 ++++++++++----- 6 files changed, 155 insertions(+), 106 deletions(-) diff --git a/app/src/main/cpp/winlator/xconnector_epoll.c b/app/src/main/cpp/winlator/xconnector_epoll.c index 1625a9235..1dd8b1f92 100644 --- a/app/src/main/cpp/winlator/xconnector_epoll.c +++ b/app/src/main/cpp/winlator/xconnector_epoll.c @@ -9,6 +9,7 @@ #include #include #include +#include #define printf(...) \ __android_log_print(ANDROID_LOG_DEBUG, "System.out", __VA_ARGS__); @@ -74,6 +75,10 @@ Java_com_winlator_cmod_runtime_display_connector_XConnectorEpoll_doEpollIndefini if (events[i].data.fd == serverFd) { int clientFd = accept(serverFd, NULL, NULL); if (clientFd >= 0) { + // Set a write timeout to avoid hanging the XServer if a client is stopped (SIGSTOP) + struct timeval tv = {.tv_sec = 2, .tv_usec = 0}; + setsockopt(clientFd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + if (addClientToEpoll) { struct epoll_event event; event.data.fd = clientFd; diff --git a/app/src/main/feature/settings/other/OtherSettingsScreen.kt b/app/src/main/feature/settings/other/OtherSettingsScreen.kt index 61977fd15..19d2497a0 100644 --- a/app/src/main/feature/settings/other/OtherSettingsScreen.kt +++ b/app/src/main/feature/settings/other/OtherSettingsScreen.kt @@ -1138,7 +1138,6 @@ private fun BackgroundPauseModeCard( 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( diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 2a3f82927..c7b00c7e0 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -2650,19 +2650,22 @@ public void onResume() { if (externalDisplayController != null) externalDisplayController.start(); SessionKeepAliveService.onResumeSession(this); - LogManager.log(TAG, "Session resumed", getApplicationContext()); - // Cancel any pending stop task and re-schedule it - handler.removeCallbacks(stopEventWatchTask); - handler.postDelayed(stopEventWatchTask, 8000); + if (!isInPictureInPictureMode()) { + // Cancel any pending stop task and re-schedule it + handler.removeCallbacks(stopEventWatchTask); + handler.postDelayed(stopEventWatchTask, 8000); + } } @Override public void onPause() { - // Cancel the scheduled stop immediately so it doesn't kill - // the watcher we are about to start below. - handler.removeCallbacks(stopEventWatchTask); - LogManager.startEventWatch(getApplicationContext(), "XServerDisplayActivity.onPause"); + if (!isInPictureInPictureMode()) { + // Cancel the scheduled stop immediately so it doesn't kill + // the watcher we are about to start below. + handler.removeCallbacks(stopEventWatchTask); + LogManager.startEventWatch(getApplicationContext(), "XServerDisplayActivity.onPause"); + } LogManager.log(TAG, "Session paused; entering background", getApplicationContext()); SessionKeepAliveService.onPauseSession(this); @@ -2708,6 +2711,12 @@ public void onPause() { if (externalDisplayController != null) externalDisplayController.stop(); } + @Override + public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); + SessionKeepAliveService.setPipMode(isInPictureInPictureMode); + } + @Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); diff --git a/app/src/main/runtime/display/connector/XConnectorEpoll.java b/app/src/main/runtime/display/connector/XConnectorEpoll.java index f052f7ee9..0674f4113 100644 --- a/app/src/main/runtime/display/connector/XConnectorEpoll.java +++ b/app/src/main/runtime/display/connector/XConnectorEpoll.java @@ -146,73 +146,85 @@ public void killConnection(Client client, String reason) { LogManager.logW(TAG, "killConnection called with null client; reason=" + reason); return; } - - // Winner of this check handles the teardown. - if (!client.markKillingOnce()) return; // already being/been torn down by another thread - int fd = client.clientSocket != null ? client.clientSocket.fd : -1; // Log immediately on entry: fd, reason, whether multithreadedClients - Commented out to avoid noise // LogManager.logI(TAG, "killConnection entry: fd=" + fd + " reason=\"" + reason + "\" multithreadedClients=" + multithreadedClients); - // FIX: Remove from map FIRST. This fixes: - // 1. Busy-spinning in shutdown() if it lost the race. - // 2. FD-reuse race where a new connection on the same FD could be deleted by this cleanup. - synchronized (connectedClients) { - connectedClients.remove(fd); + // Both the client's poll thread (on EOF) and the connector's thread (on shutdown) + // can call this. Only the winner performs the actual resource cleanup. + boolean wonRace = client.markKillingOnce(); + + if (wonRace) { + // Remove from map FIRST to avoid FD-reuse races where a new connection on + // the same FD could be deleted by this cleanup while it's being accepted. + synchronized (connectedClients) { + connectedClients.remove(fd); + } + + client.connected = false; + connectionHandler.handleConnectionShutdown(client); + + if (multithreadedClients) { + // Signal the poll thread to exit its native loop (if we are not that thread) + if (client.pollThread != null && Thread.currentThread() != client.pollThread) { + client.requestShutdown(); + } + } else { + removeFdFromEpoll(epollFd, fd); + } } - client.connected = false; - connectionHandler.handleConnectionShutdown(client); - if (multithreadedClients) { - if (client.pollThread != null && Thread.currentThread() != client.pollThread) { - client.requestShutdown(); - - // FIX: Uninterruptible join. Proceeding after an InterruptedException - // would free buffers while the poll thread is potentially still alive. - boolean interrupted = Thread.interrupted(); - try { - while (client.pollThread.isAlive()) { - try { - client.pollThread.join(); - } catch (InterruptedException e) { - interrupted = true; - } + + // BOTH winner and loser must join the thread to ensure it's dead before + // shutdown() proceeds (unless the current thread IS the poll thread). + if (multithreadedClients && client.pollThread != null && Thread.currentThread() != client.pollThread) { + // FIX: Timed join to avoid teardown hang if the poll thread is blocked + // behind a SIGSTOPped peer. The native socket now has SO_SNDTIMEO, + // so it should exit eventually. 5s grace period avoids ANRs. + boolean interrupted = Thread.interrupted(); + try { + long timeout = 5000; + long start = System.currentTimeMillis(); + while (client.pollThread.isAlive()) { + long remaining = timeout - (System.currentTimeMillis() - start); + if (remaining <= 0) { + LogManager.logW(TAG, "killConnection: pollThread for fd=" + fd + " did not exit; proceeding to avoid ANR."); + break; } - } finally { - if (interrupted) { - // Restore interrupt status and log interruption - Thread.currentThread().interrupt(); - LogManager.logW(TAG, - "Interrupted while joining pollThread for fd=" + fd + " reason=\"" + reason + "\""); + try { + client.pollThread.join(); + } catch (InterruptedException e) { + interrupted = true; } } - client.pollThread = null; + } finally { + if (interrupted) { + // Restore interrupt status and log interruption + Thread.currentThread().interrupt(); + LogManager.logW(TAG, + "Interrupted while joining pollThread for fd=" + fd + " reason=\"" + reason + "\""); + } } - closeFd(client.shutdownFd); + client.pollThread = null; } else removeFdFromEpoll(epollFd, fd); - // Free direct byte buffers and ancillary FDs to avoid resource leak warnings - 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); + // Resource cleanup only happens once, and only after ensuring the thread is joined. + if (wonRace) { + // Free direct byte buffers and ancillary FDs to avoid resource leak warnings + try { + client.releaseIOStreams(); + } catch (Exception e) { + LogManager.logW(TAG, "Exception releasing IO streams for fd=" + fd, e); + } + try { + client.clientSocket.closeAncillaryFds(); + } catch (Exception e) { + LogManager.logW(TAG, "Exception closing ancillary FDs for fd=" + fd, e); + } + if (multithreadedClients) closeFd(client.shutdownFd); + closeFd(fd); + LogManager.log(TAG, "killConnection completed: fd=" + fd + " reason=\"" + reason + "\""); } - - LogManager.log(TAG, - "killConnection completed: fd=" + fd + " reason=\"" + reason + "\""); } private void shutdown() { diff --git a/app/src/main/runtime/system/ProcessHelper.java b/app/src/main/runtime/system/ProcessHelper.java index 05988a992..22f580999 100644 --- a/app/src/main/runtime/system/ProcessHelper.java +++ b/app/src/main/runtime/system/ProcessHelper.java @@ -65,8 +65,7 @@ public abstract class ProcessHelper { public enum BackgroundPauseMode { ALL("all"), - GAME_ONLY("game_only"), - ALL_EXCEPT_GAME("all_except_game"); + GAME_ONLY("game_only"); private final String prefValue; @@ -289,13 +288,6 @@ public static void pauseAllWineProcesses() { 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; } diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 0a74749ee..d56795767 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 { private static volatile boolean isScreenLocked = false; public static volatile boolean exitingFromNotification = false; private BroadcastReceiver screenStateReceiver; + private static final AtomicBoolean appInPipMode = new AtomicBoolean(false); private PowerManager.WakeLock wakeLock; // private WifiManager.WifiLock wifiLock; @@ -90,13 +91,14 @@ public class SessionKeepAliveService extends Service { 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 + private static long hb_frequency = 0; + + private NotificationHelper notificationHelper; // 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 volatile AtomicBoolean heartbeatSignal; private int notificationId = -1; private static final String NOTIFICATION_ID_NAME = "winnative.keepAlive"; @@ -111,12 +113,12 @@ 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, 0); - if (frequency > 0) { - if (frequency < 5) + hb_frequency = prefs.getInt(PREF_HEARTBEAT_FREQUENCY, 0); + if (hb_frequency > 0) { + if (hb_frequency < 5) heartbeat_interval_ms = 5 * 1000L; else - heartbeat_interval_ms = frequency * 1000L; + heartbeat_interval_ms = hb_frequency * 1000L; } } @@ -208,6 +210,18 @@ public static void setActiveXServer(XServer xServer) { activeXServer = xServer; } + public static void setPipMode(boolean inPip) { + appInPipMode.set(inPip); + if (instance != null) { + instance.ensureForeground(); + LogManager.logI(TAG, "setPipMode: " + inPip, instance); + } + } + + private static boolean isInPictureInPictureMode() { + return appInPipMode.get(); + } + // =================================================================== // Store component tracking (Steam/Epic/GOG "I'm doing background work") // =================================================================== @@ -237,7 +251,7 @@ public static void stopComponent(Context ctx, String componentName) { public static boolean isAppInBackground() { return isAppInBackground; } public static boolean isDeviceLocked() { return isScreenLocked; } - public static boolean isAppNotVisible() { + public static boolean isAppNotVisible() { return isAppInBackground || isScreenLocked; } @@ -372,6 +386,9 @@ public void onReceive(Context context, Intent intent) { public int onStartCommand(Intent intent, int flags, int startId) { String action = intent != null ? intent.getAction() : null; + // Reset the stopping flag as we've received a new start command (or are resuming) + serviceStopping = false; + // 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. @@ -410,6 +427,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { if (!hasReason()) { Timber.tag(TAG).d("onStartCommand found no active reason; stopping immediately"); + serviceStopping = true; stopForegroundCompat(); stopSelf(); serviceRunning.set(false); @@ -555,38 +573,49 @@ private void releaseWakeLock() { private void startHeartbeat() { if (prefs == null) return; - if (heartbeatRunning || !prefs.getBoolean("enable_background_wakelock", false) || prefs.getInt(PREF_HEARTBEAT_FREQUENCY, 0) <= 0) return; - heartbeatRunning = true; - Thread t = new Thread(() -> { - while (heartbeatRunning && sessionActive.get() && isContainerPaused) { + if (heartbeatThread != null || !prefs.getBoolean("enable_background_wakelock", false) || hb_frequency <= 0) return; + + synchronized (this) { + if (heartbeatThread != null) return; + + final AtomicBoolean signal = new AtomicBoolean(true); + heartbeatSignal = signal; + + Thread t = new Thread(() -> { try { - Thread.sleep(heartbeat_interval_ms); - LogManager.log(TAG, "Heartbeat: Keeping container alive...", getApplicationContext()); + while (signal.get() && sessionActive.get() && isContainerPaused) { + Thread.sleep(heartbeat_interval_ms); + + // Verify state again after waking up from sleep + if (!signal.get() || !isContainerPaused) break; + + LogManager.log(TAG, "Heartbeat: Keeping container alive...", getApplicationContext()); + runOomSweepInternal(); + } } 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; + }, "SessionHeartbeat"); + t.setDaemon(true); + heartbeatThread = t; + t.start(); + } } private void stopHeartbeat() { - heartbeatRunning = false; - Thread t = heartbeatThread; - if (t != null) { - t.interrupt(); - heartbeatThread = null; - LogManager.log(TAG, "Heartbeat stopped", this); + synchronized (this) { + // Signal the thread to stop its loop + if (heartbeatSignal != null) { + heartbeatSignal.set(false); + heartbeatSignal = null; + } + + // Interrupt the thread if it's currently sleeping or blocked in OOM sweep + if (heartbeatThread != null) { + heartbeatThread.interrupt(); + heartbeatThread = null; + LogManager.log(TAG, "Heartbeat stopped", this); + } } } @@ -606,7 +635,10 @@ private static String getNotificationContent() { // 1. HIGHEST PRIORITY: The game/container if (sessionActive.get()) { - return isContainerPaused ? svc.getString(R.string.fg_keep_alive_notification_content_container_paused) : svc.getString(R.string.fg_keep_alive_notification_content_container_running); + return (isContainerPaused + && (isDeviceLocked() || !isInPictureInPictureMode())) + ? svc.getString(R.string.fg_keep_alive_notification_content_container_paused) + : svc.getString(R.string.fg_keep_alive_notification_content_container_running); } // 2. MEDIUM PRIORITY: Downloads From f48990f929ae13c4d9bbe918ba7c62e434e72a35 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Mon, 27 Jul 2026 21:59:34 +0200 Subject: [PATCH 57/62] Some bug fixes and improvements (8). - XServerDisplayActivity: Fixed missing recovery sweep in setupXEnvironment re-attach path by ensuring guest processes are resumed and protected on fresh activity launch. - XServerDisplayActivity: Optimized onPause and onResume by moving heavy process-walking operations to background threads to prevent UI stutters. - SessionKeepAliveService: Fixed potential NPE in teardownEnvironmentAsync by removing the instance call in the LogManager.logE line. - SessionKeepAliveService: Synchronized activeDownloads access in hasReason() to ensure thread safety and consistent state visibility. --- .../display/XServerDisplayActivity.java | 19 ++++++++++++++++--- .../system/SessionKeepAliveService.java | 9 +++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 5ef36ef86..b289eee26 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -2640,7 +2640,10 @@ public void onResume() { handler.postDelayed(savePlaytimeRunnable, SAVE_INTERVAL_MS); if (!cleaningUp && !isPaused) { - if (autoPauseContainer) ProcessHelper.resumeAllWineProcesses(); + if (autoPauseContainer) { + // Move heavy proc-walk to background + new Thread(ProcessHelper::resumeAllWineProcesses, "WineProcessResumer").start(); + } } if (taskManagerPaneVisible && taskManagerTimer == null) { @@ -2664,7 +2667,8 @@ public void onPause() { // Cancel the scheduled stop immediately so it doesn't kill // the watcher we are about to start below. handler.removeCallbacks(stopEventWatchTask); - LogManager.startEventWatch(getApplicationContext(), "XServerDisplayActivity.onPause"); + // Move heavy proc-walk to background + new Thread(() -> LogManager.startEventWatch(getApplicationContext(), "XServerDisplayActivity.onPause"), "EventWatchStart").start(); } LogManager.log(TAG, "Session paused; entering background", getApplicationContext()); SessionKeepAliveService.onPauseSession(this); @@ -2683,7 +2687,10 @@ public void onPause() { boolean cleaningUp = exitRequested.get() || sessionCleanupStarted.get() || activityDestroyed.get(); if (!cleaningUp && !isInPictureInPictureMode()) { - if (autoPauseContainer) ProcessHelper.pauseAllWineProcesses(); + if (autoPauseContainer) { + // Move heavy proc-walk to background + new Thread(ProcessHelper::pauseAllWineProcesses, "WineProcessPauser").start(); + } if (environment != null) { environment.onPause(); @@ -6770,6 +6777,12 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { winHandler.markSessionInitialized(); this.guestProgramLauncherComponent = environment.getComponent(GuestProgramLauncherComponent.class); + + // Recovery sweep: ensure everything is resumed and protected after re-attaching. + // This prevents a frozen guest if the app was killed while the container was paused. + ProcessHelper.resumeAllWineProcesses(); + ProcessHelper.protectAllWineProcesses(); + return; } } diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index d56795767..f6a8320a7 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -189,7 +189,7 @@ private static void teardownEnvironmentAsync() { 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()); + LogManager.logE(TAG, "Failed to stop environment components during session stop", e); } }, "XServerTeardown").start(); } @@ -286,7 +286,12 @@ public static void stopDownload(Context ctx, String tag) { // =================================================================== private static boolean hasReason() { - return sessionActive.get() || !activeDownloads.isEmpty() || !activeComponents.isEmpty() || + boolean hasDownload; + synchronized (activeDownloads) { + hasDownload = !activeDownloads.isEmpty(); + } + + return sessionActive.get() || hasDownload || !activeComponents.isEmpty() || (isAppNotVisible() && PrefManager.INSTANCE.getChatStayRunningOnExit()); } From 173a3f24adada221737b88c7f32d666e1b4e89ab Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Mon, 27 Jul 2026 22:52:22 +0200 Subject: [PATCH 58/62] SteamLoginActivity: Fix persistent foreground service when Steam Login is used. - Fixed memory leaks and persistent foreground service by using WeakHashMap for component owners and adding manual unregistration in SteamLoginActivity. --- .../stores/steam/SteamLoginActivity.kt | 6 ++++ .../system/SessionKeepAliveService.java | 31 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/app/src/main/feature/stores/steam/SteamLoginActivity.kt b/app/src/main/feature/stores/steam/SteamLoginActivity.kt index bb662a669..78a27211d 100644 --- a/app/src/main/feature/stores/steam/SteamLoginActivity.kt +++ b/app/src/main/feature/stores/steam/SteamLoginActivity.kt @@ -96,6 +96,12 @@ class SteamLoginActivity : FixedFontScaleComponentActivity() { } } + override fun onDestroy() { + // Manually unregister to stop the service immediately + SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM) + super.onDestroy() + } + // Root @Composable fun LoginContent(viewModel: SteamLoginViewModel) { diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index f6a8320a7..1e107a1fb 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -102,7 +102,8 @@ public class SessionKeepAliveService extends Service { private int notificationId = -1; private static final String NOTIFICATION_ID_NAME = "winnative.keepAlive"; - // Tracks active components and their notification messages + // Tracks active components and their notification messages. + // We use a synchronized WeakHashMap for the inner map to prevent Context/Activity leaks. private static final Map> activeComponents = new ConcurrentHashMap<>(); // =================================================================== @@ -228,7 +229,14 @@ private static boolean isInPictureInPictureMode() { public static void startComponent(Context ctx, String componentName, String message) { if (ctx == null || componentName == null) return; - Map owners = activeComponents.computeIfAbsent(componentName, k -> new ConcurrentHashMap<>()); + + // Use a synchronized WeakHashMap to ensure Contexts aren't pinned in memory + Map owners = activeComponents.get(componentName); + if (owners == null) { + owners = java.util.Collections.synchronizedMap(new java.util.WeakHashMap<>()); + activeComponents.put(componentName, owners); + } + owners.put(ctx, message != null ? message : ""); LogManager.log(TAG, "startComponent: " + componentName, ctx); updateForegroundState(ctx); @@ -291,10 +299,17 @@ private static boolean hasReason() { hasDownload = !activeDownloads.isEmpty(); } - return sessionActive.get() || hasDownload || !activeComponents.isEmpty() || + return sessionActive.get() || hasDownload || hasActiveComponent() || (isAppNotVisible() && PrefManager.INSTANCE.getChatStayRunningOnExit()); } + private static boolean hasActiveComponent() { + for (Map owners : activeComponents.values()) { + if (!owners.isEmpty()) return true; + } + return false; + } + // 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 @@ -656,8 +671,14 @@ private static String getNotificationContent() { return svc.getString(R.string.fg_keep_alive_notification_content_steam_chat_running); // 4. LOW PRIORITY: Active store services - // Fix: Copy keys first to avoid IndexOutOfBoundsException if map is cleared between check and access - List names = new ArrayList<>(activeComponents.keySet()); + // Filter out keys that might have been cleared by Garbage Collection + List names = new ArrayList<>(); + for (Map.Entry> entry : activeComponents.entrySet()) { + if (!entry.getValue().isEmpty()) { + names.add(entry.getKey()); + } + } + if (!activeComponents.isEmpty()) { // Define the priority order List priority = Arrays.asList(COMPONENT_STEAM, COMPONENT_EPIC, COMPONENT_GOG); From 8317eba5ed120ecb825beb7d9d693d316e4d1347 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Tue, 28 Jul 2026 11:10:31 +0200 Subject: [PATCH 59/62] LogManager: Fixed a regression in the anti-duplication PostMortem log check. - Also, fixed a discrepancy in the description of the ExitReasons log separators. --- app/src/main/runtime/system/LogManager.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 96c0d033f..e320be92b 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -828,7 +828,7 @@ object LogManager { // Separator line with reason number: 0 = newest/last, larger = older appendLine( ctx, stamped(EXIT_REASONS_FILE), "I/$TAG", - "\n---- Exit reason #${index} (1=new/last, ${infos.size-1}=oldest) ----" + "\n---- Exit reason #${index} (0=oldest, ${infos.size-1}=new/last) ----" ) appendLine( @@ -941,8 +941,7 @@ object LogManager { // 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, true), stamped(POST_MORTEM_FILE)) - if (!postMortemFile.exists()) { + if (!anyLogExists(getLogsDir(ctx, true), POST_MORTEM_FILE)) { prefs.edit { remove(PREF_POST_MORTEM_KEYS) } } From 665d7a47f6787157aab71d409a3a4f2fc24b2063 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Tue, 28 Jul 2026 17:57:59 +0200 Subject: [PATCH 60/62] =?UTF-8?q?SteamLoginActivity:=20Handling=20the=20co?= =?UTF-8?q?rrect=20exit=20from=20the=20login=20screen=20to=20stop=20the=20?= =?UTF-8?q?service=20if=20it=20is=20no=20longer=20needed.=20-=20The=20prev?= =?UTF-8?q?ious=20fix=20related=20to=20Steam=20Login=20wasn=E2=80=99t=20wo?= =?UTF-8?q?rking=20because=20the=20Login=20Screen=20remained=20alive=20beh?= =?UTF-8?q?ind=20the=20main=20activity,=20so=20onDestroy=20wasn=E2=80=99t?= =?UTF-8?q?=20being=20called.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stores/steam/SteamLoginActivity.kt | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/app/src/main/feature/stores/steam/SteamLoginActivity.kt b/app/src/main/feature/stores/steam/SteamLoginActivity.kt index 78a27211d..2b9953313 100644 --- a/app/src/main/feature/stores/steam/SteamLoginActivity.kt +++ b/app/src/main/feature/stores/steam/SteamLoginActivity.kt @@ -1,6 +1,7 @@ package com.winlator.cmod.feature.stores.steam import android.os.Bundle -import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.viewModels import androidx.activity.compose.setContent import androidx.compose.animation.* import androidx.compose.animation.core.* @@ -45,6 +46,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.winlator.cmod.R import com.winlator.cmod.feature.stores.steam.enums.LoginResult import com.winlator.cmod.feature.stores.steam.enums.LoginScreen +import com.winlator.cmod.feature.stores.steam.service.SteamService 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 @@ -65,6 +67,9 @@ private val TextSecondary = Color(0xFF7A8FA8) private val DangerRed = Color(0xFFFF7A88) class SteamLoginActivity : FixedFontScaleComponentActivity() { + + private val viewModel: SteamLoginViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -90,15 +95,22 @@ class SteamLoginActivity : FixedFontScaleComponentActivity() { outline = CardBorder, ), ) { - val viewModel: SteamLoginViewModel = viewModel() LoginContent(viewModel) } } } override fun onDestroy() { - // Manually unregister to stop the service immediately + // Manually unregister this service as reason. SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM) + + // Stop the background SteamService if we are leaving the login screen + // without a successful login. This removes the "Steam service active" reason. + val state = viewModel.loginState.value + if (state.loginResult != LoginResult.Success) { + SteamService.Companion.stop() + } + super.onDestroy() } @@ -124,6 +136,11 @@ class SteamLoginActivity : FixedFontScaleComponentActivity() { val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + // Ensure the system Back button finishes the activity properly + BackHandler { + finish() + } + Box( modifier = Modifier From b71df8e85129c07dcdfdfe5130cfda5e902538d6 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Thu, 30 Jul 2026 16:45:36 +0200 Subject: [PATCH 61/62] Fix indentation in SessionKeepAliveService.java --- app/src/main/runtime/system/SessionKeepAliveService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index 1e107a1fb..ea438a1cb 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -259,7 +259,7 @@ public static void stopComponent(Context ctx, String componentName) { public static boolean isAppInBackground() { return isAppInBackground; } public static boolean isDeviceLocked() { return isScreenLocked; } - public static boolean isAppNotVisible() { + public static boolean isAppNotVisible() { return isAppInBackground || isScreenLocked; } @@ -544,7 +544,7 @@ public void onDestroy() { serviceStopping = false; serviceRunning.set(false); - if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); +// if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); // if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); stopHeartbeat(); releaseWakeLock(); From 74f45ef6279f27e0de476a1f98ac649f6252c531 Mon Sep 17 00:00:00 2001 From: Juan-Antonio-Doe Date: Fri, 31 Jul 2026 14:20:53 +0200 Subject: [PATCH 62/62] Refine SessionKeepAliveService wake lock logic - Migrated wake lock handling in `SessionKeepAliveService` from `onPauseSession` to `ScreenStateReceiver`. - The service now acquires a wake lock when the screen is turned off and releases it when the device is unlocked (`ACTION_USER_PRESENT`). The wakelock prevents issues that occur after leaving the phone in deep sleep. So it is not necessary when the phone is unlocked. - This also prevents the wakelock from being activated in PiP mode. --- app/src/main/runtime/system/SessionKeepAliveService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java index ea438a1cb..0642ee2c6 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -139,7 +139,7 @@ public static void onPauseSession(Context ctx) { isContainerPaused = true; LogManager.log(TAG, "onPauseSession", ctx); if (instance != null) { - instance.acquireWakeLock(); +// instance.acquireWakeLock(); // Moved to onCreate > screenStateReceiver > Screen off event instance.startHeartbeat(); } updateForegroundState(ctx); @@ -164,7 +164,6 @@ public static void stopSession(Context ctx) { if (ctx == null) return; if (!sessionActive.compareAndSet(true, false)) return; isContainerPaused = false; -// LogManager.log(ctx, TAG, "stopSession"); if (instance != null) { instance.stopHeartbeat(); instance.releaseWakeLock(); @@ -189,7 +188,6 @@ private static void teardownEnvironmentAsync() { 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); } }, "XServerTeardown").start(); @@ -382,9 +380,11 @@ public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (Intent.ACTION_SCREEN_OFF.equals(action)) { isScreenLocked = true; + acquireWakeLock(); LogManager.log(TAG, "Screen turned off / device locked"); } else if (Intent.ACTION_USER_PRESENT.equals(action)) { isScreenLocked = false; + releaseWakeLock(); LogManager.log(TAG, "Device unlocked (user present)"); } else if (Intent.ACTION_SCREEN_ON.equals(action)) { // Screen on but keyguard may still be showing.