diff --git a/app/build.gradle b/app/build.gradle
index 9a83a7dea..7312e640f 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")
@@ -200,11 +206,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
]
}
}
@@ -224,7 +232,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
@@ -272,6 +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 'com.android.ndk.thirdparty:openssl:1.1.1q-beta-1'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index dcb6bc9e1..ecfb13182 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -36,6 +36,7 @@
+
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 42ca554ba..eb6e24bdc 100644
--- a/app/src/main/app/shell/UnifiedActivity.kt
+++ b/app/src/main/app/shell/UnifiedActivity.kt
@@ -1511,6 +1511,7 @@ class UnifiedActivity :
override fun onCreate(savedInstanceState: Bundle?) {
instance = this
super.onCreate(savedInstanceState)
+
if (!SetupWizardActivity.isSetupComplete(this) || !ImageFs.find(this).isUpToDate) {
startActivity(
Intent(this, SetupWizardActivity::class.java)
diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt
index 76c867d8f..0e8e3f1e9 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
@@ -8,11 +9,11 @@ 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
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 +26,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.app.shell.UnifiedActivity
import com.winlator.cmod.shared.io.AssetPaths
import com.winlator.cmod.shared.io.FileUtils
@@ -82,9 +85,41 @@ 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) }
+ 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 ->
+ 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
@@ -206,6 +241,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,
wineClasses = classes,
diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt
index 28062eba8..cfcd18697 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
@@ -6,8 +7,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
@@ -131,6 +134,16 @@ 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.material3.HorizontalDivider
+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)
@@ -144,10 +157,18 @@ 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(
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 wineClasses: List = emptyList(),
@@ -175,7 +196,16 @@ fun DebugScreen(
state: DebugState,
wineChannelOptions: List,
wineClassOptions: 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,
onWineClassesChanged: (List) -> Unit,
@@ -199,6 +229,7 @@ fun DebugScreen(
bridge: SettingsNavBridge? = null,
) {
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()
@@ -218,6 +249,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(
@@ -262,6 +310,74 @@ fun DebugScreen(
onCheckedChange = onAppDebugChanged,
)
+ 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),
+ subtitle = stringResource(R.string.settings_debug_crash_log_subtitle),
+ icon = Icons.Outlined.BugReport,
+ 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,
+ checked = state.eventWatchLog,
+ onCheckedChange = onEventWatchLogChanged,
+ )
+
+ 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)
+ },
+ )
+ }
+
+ 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 },
+ )
+ }
+
SectionLabel(stringResource(R.string.settings_debug_section_emulation), modifier = Modifier.padding(top = 8.dp))
SettingsToggleCard(
@@ -349,7 +465,10 @@ fun DebugScreen(
)
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(
@@ -563,7 +682,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,
@@ -672,6 +791,131 @@ private fun RowScope.ClassChip(
}
}
+// 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,
@@ -758,13 +1002,18 @@ 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,
+ headerContent: (@Composable () -> Unit)? = null,
+ extraContent: (@Composable (selected: Set, onToggle: (String) -> Unit) -> Unit)? = null,
+ systemTags: Set = emptySet(),
+ maxWidth: Dp = 460.dp,
) {
val selected =
remember(initiallySelected) {
@@ -849,7 +1098,7 @@ private fun WineChannelsDialog(
Box(
modifier =
Modifier
- .widthIn(max = 460.dp)
+ .widthIn(max = maxWidth)
.fillMaxWidth()
.heightIn(max = availableHeight)
.clip(RoundedCornerShape(18.dp))
@@ -859,21 +1108,21 @@ private fun WineChannelsDialog(
) {
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,
- )
+
+ headerContent?.invoke()
+
+ // Optional hint area can be provided by caller via extraContent or you can keep a default hint
Spacer(Modifier.height(12.dp))
ChannelGrid(
options = options,
+ systemTags = systemTags,
selected = selected.value,
gridState = gridState,
onViewport = { top, h ->
@@ -890,6 +1139,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))
CompositionLocalProvider(LocalPaneNav provides footerRegistry) {
Row(
@@ -917,6 +1176,231 @@ 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,
+ systemTags = remember { LogManager.getSystemTags() },
+ initiallySelected = initiallySelected,
+ onDismiss = onDismiss,
+ onConfirm = { selected -> onConfirm(mode, selected) },
+ 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(20.dp)
+ ) {
+ // 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
+ }
+ }
+ )
+ }
+
+ // 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 = ""
+ }
+ }
+ )
+ }
+ }
+ }
+
+ // Custom Tags row
+ if (customTags.isNotEmpty()) {
+ 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)
+ }
+ }
+ },
+ )
+}
+
+@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)
+ // 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,
+ ) {
+ 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))
+ // Add paneNavItem to the 'X' button container
+ .paneNavItem(
+ cornerRadius = 10.dp,
+ onActivate = { onRemove() },
+ highlightColor = NavHighlight,
+ tapToSelect = true
+ )
+ .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,
@@ -924,6 +1408,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(
@@ -948,11 +1433,14 @@ private fun ColumnScope.ChannelGrid(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
itemsIndexed(options) { index, channel ->
+ val isSystem = channel in systemTags
+ val chipAccent = if (isSystem) SystemTagColor else Accent
SelectableChannelChip(
label = channel,
isSelected = channel in selected,
isEntry = index == 0,
onToggle = { onToggle(channel) },
+ tint = chipAccent,
)
}
}
@@ -964,10 +1452,22 @@ 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 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
+ 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
diff --git a/app/src/main/feature/settings/other/OtherSettingsFragment.kt b/app/src/main/feature/settings/other/OtherSettingsFragment.kt
index 6ea11acc1..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
@@ -13,7 +14,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
@@ -32,6 +32,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
@@ -168,6 +169,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()
+ },
onExternalDisplayOutputChanged = { checked ->
preferences.edit { putBoolean("external_display_output", checked) }
refresh()
@@ -236,6 +254,12 @@ class OtherSettingsFragment : Fragment() {
openInBrowser = preferences.getBoolean("open_with_android_browser", false),
shareClipboard = preferences.getBoolean("share_android_clipboard", 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)
+ ),
externalDisplayOutput = preferences.getBoolean("external_display_output", false),
imagefsInstallProgress = uiState.imagefsInstallProgress,
)
diff --git a/app/src/main/feature/settings/other/OtherSettingsScreen.kt b/app/src/main/feature/settings/other/OtherSettingsScreen.kt
index c3b21e501..60841abb9 100644
--- a/app/src/main/feature/settings/other/OtherSettingsScreen.kt
+++ b/app/src/main/feature/settings/other/OtherSettingsScreen.kt
@@ -1,9 +1,15 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package com.winlator.cmod.feature.settings
+import androidx.compose.animation.AnimatedVisibility
+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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -24,12 +30,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.Monitor
@@ -39,16 +49,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
@@ -59,17 +73,22 @@ 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.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.focus.rememberSettingsContentNav
import com.winlator.cmod.shared.ui.nav.DialogPaneNav
@@ -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(
@@ -109,6 +129,10 @@ data class OtherSettingsState(
val openInBrowser: Boolean = false,
val shareClipboard: 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 externalDisplayOutput: Boolean = false,
val imagefsInstallProgress: Int? = null,
)
@@ -152,6 +176,10 @@ fun OtherSettingsScreen(
onOpenInBrowserChanged: (Boolean) -> Unit,
onShareClipboardChanged: (Boolean) -> Unit,
onEnableBackgroundSessionChanged: (Boolean) -> Unit,
+ onEnableAutoPauseChanged: (Boolean) -> Unit,
+ onUseBackgroundWakelockChanged: (Boolean) -> Unit,
+ onHeartbeatFrequencyChanged: (Int) -> Unit,
+ onBackgroundPauseModeChanged: (ProcessHelper.BackgroundPauseMode) -> Unit,
onExternalDisplayOutputChanged: (Boolean) -> Unit,
onRunSetupWizard: () -> Unit,
onReinstallImagefs: () -> Unit,
@@ -259,24 +287,64 @@ fun OtherSettingsScreen(
onCheckedChange = onXinputDisabledChanged,
)
- SettingsToggleCard(
- title = stringResource(R.string.session_drawer_output_to_display),
- subtitle = stringResource(R.string.settings_external_display_output_summary),
- icon = Icons.Outlined.Monitor,
- checked = state.externalDisplayOutput,
- onCheckedChange = onExternalDisplayOutputChanged,
- )
-
- SectionLabel(stringResource(R.string.settings_other_section_integration), modifier = Modifier.padding(top = 8.dp))
+ SectionLabel(stringResource(R.string.settings_other_section_background), modifier = Modifier.padding(top = 8.dp))
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,
)
+ 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,
+ )
+
+ 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,
+ )
+ }
+
+ AnimatedVisibility(
+ visible = state.enableBackgroundSession && state.useBackgroundWakelock,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically(),
+ ) {
+ HeartbeatFrequencyCard(
+ currentFrequency = state.heartbeatFrequency,
+ onFrequencyChanged = onHeartbeatFrequencyChanged,
+ )
+ }
+
+ BackgroundPauseModeCard(
+ currentMode = state.backgroundPauseMode,
+ 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),
+ icon = Icons.Outlined.Monitor,
+ checked = state.externalDisplayOutput,
+ onCheckedChange = onExternalDisplayOutputChanged,
+ )
+
SettingsToggleCard(
title = stringResource(R.string.settings_general_enable_file_provider),
subtitle = stringResource(R.string.settings_general_file_provider_summary),
@@ -383,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,
@@ -530,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),
@@ -668,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),
) {
@@ -1034,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(
@@ -1045,3 +1118,289 @@ 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()
+ .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)
+ .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))
+ .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(
+ selected = currentMode == mode,
+ onClick = { onModeChanged(mode) },
+ colors = RadioButtonDefaults.colors(
+ 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)) {
+ 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,
+) {
+ // 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
+
+ // 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(
+ 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)
+ .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,
+ )
+ 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)
+ )
+ }
+ // 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,
+ )
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+// 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 || parsed == 0 -> 0 // revert to default on empty / non-numeric -> disabled
+ parsed < 5 -> 5 // clamp to minimum
+ else -> parsed
+ }
+ onFrequencyChanged(committed)
+}
diff --git a/app/src/main/feature/setup/SetupWizardActivity.kt b/app/src/main/feature/setup/SetupWizardActivity.kt
index 08592f8aa..e46193eef 100644
--- a/app/src/main/feature/setup/SetupWizardActivity.kt
+++ b/app/src/main/feature/setup/SetupWizardActivity.kt
@@ -139,6 +139,8 @@ import java.io.File
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,
@@ -533,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)
@@ -593,7 +597,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)
) {
@@ -890,7 +894,24 @@ 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 (!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) {
+ 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()
loadAdvancedProfiles()
@@ -1009,7 +1030,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/feature/stores/epic/service/EpicService.kt b/app/src/main/feature/stores/epic/service/EpicService.kt
index 20b9b230e..2e83e5d74 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
-// Foreground service facade for Epic auth, library sync, downloads, and cloud saves.
+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() {
+ /*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) {
@@ -65,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
}
@@ -80,24 +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 {
- service.notificationHelper.cancel()
- }.onFailure { Timber.w(it, "Failed to cancel EpicService notification during shutdown") }
+ /*runCatching {
+ if (service::notificationHelper.isInitialized)
+ service.notificationHelper.cancel(service.notificationID)
+ }.onFailure { Timber.w(it, "Failed to cancel EpicService notification during shutdown") }*/
service.stopSelf()
}
}
@@ -1126,27 +1159,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(
@@ -1259,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)
@@ -1272,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) {
@@ -1417,14 +1427,14 @@ class EpicService : Service() {
}
scope.cancel()
- stopForeground(STOP_FOREGROUND_REMOVE)
- notificationHelper.cancel()
+ 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 b903a73d5..34db209a5 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
-// Foreground service facade for GOG auth, library sync, downloads, and cloud saves.
+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() {
+
+ /*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"
@@ -62,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
}
@@ -77,24 +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 {
- service.notificationHelper.cancel()
- }.onFailure { Timber.w(it, "Failed to cancel GOGService notification during shutdown") }
+ /*runCatching {
+ if (service::notificationHelper.isInitialized)
+ service.notificationHelper.cancel(service.notificationID)
+ }.onFailure { Timber.w(it, "Failed to cancel GOGService notification during shutdown") }*/
service.stopSelf()
}
}
@@ -1440,36 +1481,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) {
@@ -1574,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)
@@ -1587,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) {
@@ -1663,14 +1673,14 @@ class GOGService : Service() {
setSyncInProgress(false)
scope.cancel()
- stopForeground(STOP_FOREGROUND_REMOVE)
- notificationHelper.cancel()
+ 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")
}
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 4de8c0117..936f6cbe7 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,18 +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
@@ -121,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
@@ -129,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
@@ -152,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,6 +187,10 @@ class SteamService : Service() {
lateinit var downloadingAppInfoDao: DownloadingAppInfoDao
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
@@ -261,7 +252,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()
@@ -6951,8 +6943,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")
}
@@ -6977,12 +6973,14 @@ 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()
- steamInstance.notificationHelper.cancelBackgroundRunning()
- }.onFailure { Timber.w(it, "Failed to cancel SteamService notification during shutdown") }
+ /*runCatching {
+ if (steamInstance::notificationHelper.isInitialized) {
+ steamInstance.notificationHelper.cancel(steamInstance.notificationID)
+ steamInstance.notificationHelper.cancelBackgroundRunning()
+ }
+ }.onFailure { Timber.w(it, "Failed to cancel SteamService notification during shutdown") }*/
steamInstance.stopSelf()
}
steamInstance.scope.launch {
@@ -7001,8 +6999,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 {
@@ -7562,8 +7563,13 @@ class SteamService : Service() {
instance = this
notificationHelper = NotificationHelper(applicationContext)
- val notification = notificationHelper.createForegroundNotification("Steam Service is running")
- startForeground(1, notification)
+ // 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
.seedFromPrefManager(applicationContext)
@@ -7668,6 +7674,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
}
@@ -7684,9 +7695,12 @@ class SteamService : Service() {
downloadInfo.persistProgressSnapshot(force = true)
}
- stopForeground(STOP_FOREGROUND_REMOVE)
- notificationHelper.cancel()
- notificationHelper.cancelBackgroundRunning()
+ /*stopForeground(STOP_FOREGROUND_REMOVE)
+ notificationHelper.cancel(notificationID)
+ notificationHelper.cancelBackgroundRunning()*/
+
+ // Safety unregister in case of unexpected destruction
+ SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM)
if (!isStopping) {
scope.launch { stop() }
@@ -7746,13 +7760,26 @@ class SteamService : Service() {
// 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")
@@ -7769,7 +7796,13 @@ class SteamService : Service() {
/** App went to the background — arm the deferred suspend check. */
private fun 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,
@@ -7777,7 +7810,8 @@ class SteamService : Service() {
)
notificationHelper.cancel()
}.onFailure { Timber.w(it, "Failed to show Steam background-chat notification") }
- }
+ }*/
+ // ToDo end.
scheduleBackgroundSuspendCheck()
}
@@ -7802,7 +7836,9 @@ class SteamService : Service() {
private fun connectionCriticalWork(): String? =
when {
DownloadCoordinator.hasActiveDownload() -> "a download is active"
- 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"
PrefManager.chatStayRunningOnExit -> "background chat is enabled"
else -> null
@@ -7826,12 +7862,17 @@ class SteamService : Service() {
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
}
@@ -8010,6 +8051,7 @@ class SteamService : Service() {
runCatching { s.close() }
}
wnSession = null
+ SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM)
clearValues()
}
@@ -8033,6 +8075,8 @@ class SteamService : Service() {
_unifiedFriends?.close()
_unifiedFriends = null
+ familyGroupMembers.clear()
+
isStopping = false
retryAttempt = 0
reconnectJob?.cancel()
@@ -8046,8 +8090,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
@@ -8077,7 +8125,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 =
@@ -8190,7 +8238,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))
@@ -9057,4 +9107,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/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 a8a9e2387..80fd2008d 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -1527,6 +1527,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
@@ -1566,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 dcb6f9137..6a44ce8e7 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -1526,6 +1526,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
@@ -1565,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 2afa57152..fd4c0dc6d 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -1526,6 +1526,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
@@ -1565,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 e4a199276..c86811811 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -1526,6 +1526,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
@@ -1565,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 聊天正在背景執行
+ :服務執行中
+ :服務執行中
+ 和
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d55a29024..fca494479 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1065,6 +1065,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
@@ -1124,6 +1139,25 @@ E.g. META for META key, \n
Unable to take persistable permissions: %1$s
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).
+ 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
@@ -1564,4 +1598,12 @@ Installed path:
Rename failed
Create folder failed
Refresh
+
+ 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 c8508b6f3..41b9dfd0d 100644
--- a/app/src/main/runtime/display/XServerDisplayActivity.java
+++ b/app/src/main/runtime/display/XServerDisplayActivity.java
@@ -78,6 +78,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;
@@ -183,6 +184,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;
@@ -191,6 +193,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;
@@ -486,6 +489,8 @@ private boolean isAnyControllerConnected() {
private boolean isDarkMode;
private boolean enableLogsMenu;
+ private boolean autoPauseContainer;
+ private static final String TAG = "XServerDisplayActivity";
private GuestProgramLauncherComponent guestProgramLauncherComponent;
private EnvVars overrideEnvVars;
@@ -790,7 +795,7 @@ protected void onNewIntent(Intent intent) {
boolean bootExeChanged = !(incomingBootExe != null ? incomingBootExe : "").equals(currentBootExe);
if (shortcutChanged || shortcutUuidChanged || containerChanged || bootExeChanged) {
- Log.d("XServerDisplayActivity", "onNewIntent: launch target changed, cleaning up before recreation");
+ LogManager.log(TAG, "onNewIntent: launch target changed, cleaning up before recreation", this);
switchLaunchTargetAfterCleanup(intent);
}
}
@@ -882,7 +887,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(TAG, "Switch launch already in progress; ignoring duplicate target intent", this);
return;
}
@@ -899,7 +904,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(TAG, "Switch cleanup finished after activity was destroyed", null, this);
return;
}
setIntent(relaunchIntent);
@@ -915,7 +920,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(
@@ -931,7 +936,7 @@ public void onCreate(Bundle savedInstanceState) {
km.requestDismissKeyguard(this, null);
}
} catch (Throwable t) {
- Log.w("XServerDisplayActivity",
+ Log.w(TAG,
"requestDismissKeyguard failed: " + t.getMessage());
}
}
@@ -939,10 +944,16 @@ 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());
-
+
setContentView(R.layout.xserver_display_activity);
xServerDisplayFrame = new FrameLayout(this);
xServerDisplayFrame.setId(R.id.FLXServerDisplay);
@@ -964,7 +975,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);
@@ -974,6 +985,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);
@@ -1026,7 +1038,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.");
}
};
@@ -1136,7 +1148,7 @@ public void handleOnBackPressed() {
String dataPath = resolveDesktopPathFromUri(launchData);
if (dataPath != null && !dataPath.isEmpty()) {
shortcutPath = dataPath;
- Log.d("XServerDisplayActivity", "Resolved shortcut path from VIEW data: " + shortcutPath);
+ Log.d(TAG, "Resolved shortcut path from VIEW data: " + shortcutPath);
}
}
@@ -1208,13 +1220,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;
}
@@ -1323,12 +1335,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(TAG, "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(TAG, "Failed to activate overridden container with ID: " + newContainerId, null, this);
finish();
return;
}
@@ -2392,8 +2404,7 @@ public void onResume() {
handler.postDelayed(savePlaytimeRunnable, SAVE_INTERVAL_MS);
if (!cleaningUp && !isPaused) {
- ProcessHelper.resumeAllWineProcesses();
- SessionKeepAliveService.onResumeSession(this);
+ if (autoPauseContainer) ProcessHelper.resumeAllWineProcesses();
}
if (taskManagerPaneVisible && taskManagerTimer == null) {
@@ -2401,10 +2412,19 @@ public void onResume() {
}
if (externalDisplayController != null) externalDisplayController.start();
+
+ SessionKeepAliveService.onResumeSession(this);
+
+ LogManager.log(TAG, "Session resumed", getApplicationContext());
+ handler.postDelayed(LogManager::stopEventWatch, 8000);
}
@Override
public void onPause() {
+ LogManager.startEventWatch(getApplicationContext(), "onPause");
+ LogManager.log(TAG, "Session paused; entering background", getApplicationContext());
+ SessionKeepAliveService.onPauseSession(this);
+
super.onPause();
isVolumeUpPressed = false;
isVolumeDownPressed = false;
@@ -2417,6 +2437,8 @@ public void onPause() {
boolean cleaningUp = exitRequested.get() || sessionCleanupStarted.get() || activityDestroyed.get();
if (!cleaningUp && !isInPictureInPictureMode()) {
+ if (autoPauseContainer) ProcessHelper.pauseAllWineProcesses();
+
if (environment != null) {
environment.onPause();
xServerView.onPause();
@@ -2566,12 +2588,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(TAG, "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(TAG,
+ "Steam wrapper terminated with status " + status + "; watching Wine processes before exiting", this);
Executors.newSingleThreadExecutor().execute(() -> {
try {
@@ -2604,18 +2626,18 @@ private boolean shouldWatchSteamTermination(int status) {
}
}
- Log.d("XServerDisplayActivity", "Steam exit watch snapshot: " + activeNames);
+ LogManager.log(TAG, "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(TAG, "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(TAG,
+ "No non-core Steam/game process appeared after wrapper exit; exiting session", this);
requestExitOnUiThread("steam wrapper exited without spawning a game");
return;
}
@@ -2625,12 +2647,12 @@ private boolean shouldWatchSteamTermination(int status) {
}
if (!exitRequested.get() && !activityDestroyed.get()) {
- Log.d("XServerDisplayActivity", "Steam exit watch timed out; exiting session");
+ LogManager.log(TAG, "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(TAG, "Steam exit watch interrupted", e, this);
if (!exitRequested.get() && !activityDestroyed.get()) {
requestExitOnUiThread("steam exit watch interrupted");
}
@@ -2644,29 +2666,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(TAG, "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(TAG, "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(TAG, "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(TAG, "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(TAG, "Skipping exit request after teardown: " + reason, this);
return;
}
exit();
@@ -2675,15 +2697,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(TAG, "Starting session cleanup from " + trigger, this);
try {
if (perfController != null) perfController.stop();
} catch (Throwable t) {
- Log.w("XServerDisplayActivity", "perfController.stop() failed", t);
+ Timber.w(t, "perfController.stop() failed");
}
return true;
}
- Log.d("XServerDisplayActivity", "Session cleanup already in progress; ignoring " + trigger);
+ LogManager.log(TAG, "Session cleanup already in progress; ignoring " + trigger, this);
return false;
}
@@ -2750,14 +2772,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(TAG, "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);
}
}
@@ -2917,13 +2939,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) {
@@ -2962,8 +2984,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 {
@@ -3005,11 +3028,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;
}
@@ -3037,14 +3060,14 @@ private void exit() {
ProcessHelper.drainDeadChildren("activity exit cleanup");
ProcessHelper.scheduleDeadChildReapSweep("activity exit cleanup", 4000, 200);
if (!remaining.isEmpty()) {
- Log.e("XServerDisplayActivity", "Exit cleanup still has remaining session processes: " + remaining);
+ Log.e(TAG, "Exit cleanup still has remaining session processes: " + remaining);
}
if (environment != null) {
environment.stopEnvironmentComponents();
environment = null;
}
- Log.d("XServerDisplayActivity", "Process snapshot after environment stop: "
- + ProcessHelper.listRunningWineProcessDetails());
+ LogManager.log(TAG, "Process snapshot after environment stop: "
+ + ProcessHelper.listRunningWineProcessDetails(), this);
stopXServer("exit");
wineRequestHandler = null;
midiHandler = null;
@@ -3067,13 +3090,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.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();
}
@@ -3798,6 +3850,7 @@ protected void onDestroy() {
if (exitRequested.get()) {
SessionKeepAliveService.stopSession(this);
}
+ LogManager.stopEventWatch();
super.onDestroy();
if (!switchLaunchInProgress.get()) {
@@ -3824,7 +3877,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");
@@ -5454,8 +5507,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();
}
@@ -6454,7 +6507,7 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException {
XEnvironment existingEnv = SessionKeepAliveService.getActiveEnvironment();
XServer existingXServer = SessionKeepAliveService.getActiveXServer();
if (existingEnv != null && existingXServer != null) {
- Log.i("XServerDisplayActivity", "Re-attaching to existing background session environment");
+ Log.i(TAG, "Re-attaching to existing background session environment");
this.environment = existingEnv;
this.xServer = existingXServer;
this.environment.setContext(this);
@@ -6592,7 +6645,7 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException {
container.isNeedsUnpacking(),
currentUnpackFiles);
} catch (Exception e) {
- Log.e("XServerDisplayActivity", "preUnpack failed", e);
+ Log.e(TAG, "preUnpack failed", e);
}
});
} else if ("GOG".equals(prereqGameSource) || "EPIC".equals(prereqGameSource)) {
@@ -6601,7 +6654,7 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException {
installMonoIfNeeded(guestProgramLauncherComponent);
installGeckoIfNeeded(guestProgramLauncherComponent);
} catch (Exception e) {
- Log.e("XServerDisplayActivity", "preUnpack failed", e);
+ Log.e(TAG, "preUnpack failed", e);
}
});
}
@@ -7045,7 +7098,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();
if (isDependencyInstall) {
@@ -8172,8 +8225,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",
@@ -9708,11 +9759,11 @@ private boolean installMonoIfNeeded(GuestProgramLauncherComponent launcher) {
// Any installed Mono is kept as-is; prefix repair clears the marker to force a reinstall.
String installedVersion = container.getExtra("mono_version", null);
if (installedVersion != null) {
- Log.d("XServerDisplayActivity", "Mono v" + installedVersion + " already installed in container " + container.id + ", skipping");
+ Log.d(TAG, "Mono v" + installedVersion + " already installed in container " + container.id + ", skipping");
return true;
}
if (hasInstalledComponentPrefix("mono")) {
- Log.d("XServerDisplayActivity", "Mono already installed via components in container " + container.id + ", skipping");
+ Log.d(TAG, "Mono already installed via components in container " + container.id + ", skipping");
return true;
}
@@ -9720,13 +9771,13 @@ private boolean installMonoIfNeeded(GuestProgramLauncherComponent launcher) {
String requiredVersion = SteamClientManager.detectRequiredMonoVersion(this, winePath);
if (requiredVersion == null) {
- Log.w("XServerDisplayActivity", "Could not detect required Mono version, skipping");
+ Log.w(TAG, "Could not detect required Mono version, skipping");
return false;
}
String monoWinePath = SteamClientManager.getMonoMsiWinePath(this, winePath);
if (monoWinePath == null) {
- Log.w("XServerDisplayActivity", "Mono MSI not available (no internet?), will retry next launch");
+ Log.w(TAG, "Mono MSI not available (no internet?), will retry next launch");
return false;
}
@@ -9736,22 +9787,22 @@ private boolean installMonoIfNeeded(GuestProgramLauncherComponent launcher) {
java.util.regex.Pattern.compile("wine-mono-(\\d+\\.\\d+\\.\\d+)").matcher(monoWinePath);
if (monoMsiMatcher.find()) actualVersion = monoMsiMatcher.group(1);
if (!actualVersion.equals(requiredVersion)) {
- Log.w("XServerDisplayActivity", "Mono fallback: required v" + requiredVersion
+ Log.w(TAG, "Mono fallback: required v" + requiredVersion
+ " but installing v" + actualVersion + " (" + monoWinePath + ")");
}
try {
- Log.d("XServerDisplayActivity", "Installing Wine Mono v" + actualVersion
+ Log.d(TAG, "Installing Wine Mono v" + actualVersion
+ " (" + monoWinePath + ") in container " + container.id + "...");
String monoCmd = "wine msiexec /i " + monoWinePath + " && wineserver -k";
launcher.execShellCommand(monoCmd);
container.putExtra("mono_installed", "true");
container.putExtra("mono_version", actualVersion);
container.saveData();
- Log.d("XServerDisplayActivity", "Mono v" + actualVersion + " installed in container " + container.id);
+ Log.d(TAG, "Mono v" + actualVersion + " installed in container " + container.id);
return true;
} catch (Exception e) {
- Log.w("XServerDisplayActivity", "Mono msiexec failed, will retry next launch", e);
+ Log.w(TAG, "Mono msiexec failed, will retry next launch", e);
return false;
}
}
@@ -9767,12 +9818,12 @@ private boolean hasInstalledComponentPrefix(String prefix) {
private void installGeckoIfNeeded(GuestProgramLauncherComponent launcher) {
String installedGecko = container.getExtra("gecko_version", null);
if (installedGecko != null) {
- Log.d("XServerDisplayActivity", "Gecko v" + installedGecko + " already installed in container "
+ Log.d(TAG, "Gecko v" + installedGecko + " already installed in container "
+ container.id + ", skipping");
return;
}
if (hasInstalledComponentPrefix("gecko")) {
- Log.d("XServerDisplayActivity", "Gecko already installed via components in container "
+ Log.d(TAG, "Gecko already installed via components in container "
+ container.id + ", skipping");
return;
}
@@ -9780,12 +9831,12 @@ private void installGeckoIfNeeded(GuestProgramLauncherComponent launcher) {
java.util.List geckoWinePaths = SteamClientManager.getGeckoMsiWinePaths(this);
if (geckoWinePaths.size() < 2) {
- Log.w("XServerDisplayActivity", "Gecko MSIs not available (no internet?), will retry next launch");
+ Log.w(TAG, "Gecko MSIs not available (no internet?), will retry next launch");
return;
}
try {
- Log.d("XServerDisplayActivity", "Installing Wine Gecko v" + geckoVersion
+ Log.d(TAG, "Installing Wine Gecko v" + geckoVersion
+ " in container " + container.id + "...");
StringBuilder geckoCmd = new StringBuilder();
for (String p : geckoWinePaths) {
@@ -9796,9 +9847,9 @@ private void installGeckoIfNeeded(GuestProgramLauncherComponent launcher) {
launcher.execShellCommand(geckoCmd.toString());
container.putExtra("gecko_version", geckoVersion);
container.saveData();
- Log.d("XServerDisplayActivity", "Gecko v" + geckoVersion + " installed in container " + container.id);
+ Log.d(TAG, "Gecko v" + geckoVersion + " installed in container " + container.id);
} catch (Exception e) {
- Log.w("XServerDisplayActivity", "Gecko msiexec failed, will retry next launch", e);
+ Log.w(TAG, "Gecko msiexec failed, will retry next launch", e);
}
}
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 b794e761b..ba20751c3 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;
+ }
+
+ 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
client.connected = false;
connectionHandler.handleConnectionShutdown(client);
if (multithreadedClients) {
@@ -147,6 +162,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;
}
}
@@ -155,12 +175,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() {
@@ -170,7 +205,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/ui/XServerSurfaceView.java b/app/src/main/runtime/display/ui/XServerSurfaceView.java
index 2f8ae7688..8b29be2a1 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;
@@ -38,6 +41,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(
@@ -95,14 +100,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 ----------------------------------------------
@@ -115,9 +124,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() {
@@ -139,6 +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");
}
return;
}
@@ -151,7 +163,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
@@ -161,10 +175,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/LogManager.kt b/app/src/main/runtime/system/LogManager.kt
index 38b2374d3..63a66b9fe 100644
--- a/app/src/main/runtime/system/LogManager.kt
+++ b/app/src/main/runtime/system/LogManager.kt
@@ -1,24 +1,222 @@
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.annotation.RequiresApi
+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
+import androidx.core.content.edit
object LogManager {
private const val TAG = "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
+ 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 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.
+ // 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",
+ "OomProtectCheck",
+ "GuestProgramLauncherComponent",
+ "XServerLeakCheck",
+ )
+
+ 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"
+ private const val PREF_LOGGED_EXIT_KEYS = "logged_exit_keys"
+ private const val PREF_POST_MORTEM_KEYS = "post_mortem_keys"
+
+ // ── 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 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 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() = cachedAppDebugEnabled
+ @JvmStatic
+ val isEventWatchEnabled: Boolean get() = cachedEventWatchEnabled
+
+ private var crashHandlerInitialized = false
+
+ private fun resolveContext(context: Context?): Context? = context?.applicationContext ?: appContext
+
+ /**
+ * 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.
+ */
+ @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 ->
+ if (key in RELEVANT_KEYS) refreshCaches(app)
+ }
+ prefs.registerOnSharedPreferenceChangeListener(listener)
+ prefsListener = listener
+ }
+
+// Log.d(TAG, "LogManager initialized, context name=${app.javaClass.name}, appContext=$appContext")
+
+ // Set up uncaught exception handler
+ 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)
+ }
+ crashHandlerInitialized = true
+ }
+
+ 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) {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ 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)
+ }
+
+ private fun splitPref(prefs: SharedPreferences, key: String): Set =
+ prefs.getString(key, null)
+ ?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet()
+ ?: emptySet()
+
+ // ── Logs directory ───────────────────────────────────────────────
@JvmStatic
fun getLogsDir(context: Context): File {
- val baseDir = context.getExternalFilesDir(null) ?: context.filesDir
- val dir = File(baseDir, "logs")
+ 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 currentPath = resolvePathString(
+ prefs.getString("winlator_path_uri", null), SettingsConfig.DEFAULT_WINLATOR_PATH, context,
+ )
+
+ Timber.d("Winnative pathString: $currentPath")
+
+ val dir = File(currentPath, "logs")
if (!dir.exists()) dir.mkdirs()
return dir
}
+ 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
+ } catch (e: Exception) {
+ Timber.tag(TAG).w("Failed to resolve winlator_path_uri ($uriStr): ${e.message}")
+ uriStr
+ }
+ }
+
fun isAnyLoggingEnabled(context: Context): Boolean {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
return prefs.getBoolean("enable_wine_debug", false) ||
@@ -26,7 +224,7 @@ object LogManager {
prefs.getBoolean("enable_steam_logs", false) ||
prefs.getBoolean("enable_input_logs", false) ||
prefs.getBoolean("enable_download_logs", false) ||
- prefs.getBoolean("enable_app_debug", false)
+ cachedAppDebugEnabled
}
fun updateLoggingState(context: Context) {
@@ -42,8 +240,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")))
}
}
@@ -56,6 +253,76 @@ object LogManager {
startAppLogging(context)
}
+ // ── 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 + DEVELOPER_TAGS + BASELINE_SYSTEM_TAGS.keys + 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(",")) }
+ }
+
+ @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(","))
+ }
+ }
+
+ @JvmStatic
+ fun setSelectedTags(context: Context, tags: Set) {
+ PreferenceManager.getDefaultSharedPreferences(context).edit {
+ putString(PREF_SELECTED_TAGS, tags.joinToString(","))
+ }
+ }
+
+ @JvmStatic
+ fun getSelectedTags(): Set = cachedSelectedTags
+
+ @JvmStatic
+ fun setTagFilterMode(context: Context, mode: TagFilterMode) {
+ PreferenceManager.getDefaultSharedPreferences(context).edit {
+ putString(PREF_TAG_FILTER_MODE, mode.name)
+ }
+ }
+
+ @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?) {
+ 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) {
@@ -64,8 +331,7 @@ object LogManager {
return
}
- val logsDir = getLogsDir(context)
- val logFile = File(logsDir, "logcat.log")
+ val logFile = File(getLogsDir(context), "logcat.log")
try {
stopLogcat()
@@ -76,7 +342,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}")
}
}
@@ -90,22 +356,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 (!cachedAppDebugEnabled) return
+ val logFile = File(getLogsDir(context), "application.log")
try {
stopAppLogging()
@@ -117,9 +379,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}")
}
}
@@ -129,7 +391,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}")
}
}
@@ -176,5 +438,505 @@ 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 {
+ return 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 (!cachedAppDebugEnabled) return
+ baseLog(Level.DEBUG, tag, message(), null, context)
+ }
+
+ inline fun logI(tag: String, context: Context? = null, message: () -> String) {
+ if (!cachedAppDebugEnabled) return
+ baseLog(Level.INFO, tag, message(), null, context)
+ }
+
+ inline fun logW(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) {
+ if (!cachedAppDebugEnabled) return
+ baseLog(Level.WARN, tag, message(), null, context)
+ }
+
+ inline fun logE(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) {
+ if (!cachedAppDebugEnabled) 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 (!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, APP_LOG_FILE, "${level.prefix}/$tag", fullMessage)
+ }
+
+ private fun appendLine(context: Context, fileName: String, level: String, message: String) {
+ 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}")
+ }
+ }
+
+ // ── 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 startEventWatch(context: Context, label: String = "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" }
+
+ 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)
+ }
+
+ eventWatchProcess = Runtime.getRuntime().exec(command.toTypedArray())
+
+ closeProcessStdin(eventWatchProcess)
+ } catch (e: Exception) {
+ logE(TAG, null, context) { "Failed to start event watch: ${e.message}" }
+ }
+ }
+
+ @JvmStatic
+ fun stopEventWatch() {
+ try {
+ eventWatchProcess?.let(::destroyProcess)
+ eventWatchProcess = null
+ } catch (e: Exception) {
+ Timber.e("Failed to stop pause watch: ${e.message}")
+ }
+ }
+
+ private fun buildLogcatFilterSpecArgs(): List {
+ val spec = mutableListOf()
+ val selectedBaseline = BASELINE_SYSTEM_TAGS.keys.filter { it in cachedSelectedTags }.toSet()
+ val selectedApp = cachedSelectedTags - BASELINE_SYSTEM_TAGS.keys
+
+ when (cachedTagFilterMode) {
+ TagFilterMode.ALL -> {
+ // 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")
+ BASELINE_SYSTEM_TAGS.forEach { (tag, priority) -> spec.add("$tag:$priority") }
+ }
+ TagFilterMode.INCLUDE -> {
+ // 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
+ }
+
+ // ── 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
+ // 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 (!cachedExitReasonLogEnabled && !cachedCrashLogEnabled) 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)
+
+ 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()) {
+ // 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
+ }
+
+ for ((index, info) in infos.withIndex()) {
+ 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",
+ "\n---- Exit reason #${index} (0=new/last, ${maxExitReasons}=oldest) ----"
+ )
+
+ 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)}",
+ )
+ loggedKeys.add(exitKey)
+ wroteSomething = true
+ }
+ if (cachedCrashLogEnabled && crashKey !in loggedKeys) {
+ 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 ->
+ val rawTrace = input.bufferedReader().readText()
+ val summary = extractTraceExcerpt(rawTrace, info.reason, maxFrames = 20)
+ appendLine(
+ ctx, CRASH_FILE, "I/$TAG",
+ "\n=== Historical $type Detected ===\n" +
+ "PID: ${info.pid} | Timestamp: ${Date(info.timestamp)}\n" +
+ "Description: ${info.description}\n" +
+ "Trace Summary:\n$summary\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}")
+ }
+ }
+ // Mark as crash-processed to avoid re-scanning non-crash reasons
+ loggedKeys.add(crashKey)
+ wroteSomething = true
+ }
+ }
+
+ if (wroteSomething) {
+ persistKeys(prefs, PREF_LOGGED_EXIT_KEYS, loggedKeys)
+ }
+ } catch (e: Exception) {
+ Timber.tag(TAG).e("Failed to read exit reasons: ${e.message}")
+ }
+ }
+
+ 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("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 {
+ 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")
+ }
+ }
+
+ /**
+ * 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)
+
+ // 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) }
+ }
+
+ try {
+ val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+ val infos = am.getHistoricalProcessExitReasons(ctx.packageName, 0, 5)
+ if (infos.isEmpty()) return
+
+ 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}")
+ }
+ }
+
+ @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 ---")
+ }
+ }
+ } 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()
+
+ private fun persistKeys(prefs: SharedPreferences, prefKey: String, keys: Set) {
+ prefs.edit {
+ putString(prefKey, keys.sortedDescending().take(20).joinToString(","))
+ }
+ }
+
+ // ── 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 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
+ }
+ }
+ out.toString().trimEnd().ifEmpty { lines.take(maxFrames).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 -> ""
+ }
+ }
+
+ /**
+ * 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/ProcessHelper.java b/app/src/main/runtime/system/ProcessHelper.java
index 15ef604bc..e669171a5 100644
--- a/app/src/main/runtime/system/ProcessHelper.java
+++ b/app/src/main/runtime/system/ProcessHelper.java
@@ -20,6 +20,8 @@
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
+import timber.log.Timber;
+
public abstract class ProcessHelper {
private static final String TAG = "ProcessHelper";
private static final int MAX_PROCESS_DETAIL_LENGTH = 240;
@@ -61,6 +63,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 = "OomProtectCheck";
+
public static native int reapDeadChildrenNow();
public static native void startNativeReaperWindow(int durationMs);
@@ -219,16 +244,31 @@ 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() {
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);
@@ -237,15 +277,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);
}
}
@@ -676,4 +731,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 8b38e656b..23bb8c883 100644
--- a/app/src/main/runtime/system/SessionKeepAliveService.java
+++ b/app/src/main/runtime/system/SessionKeepAliveService.java
@@ -1,32 +1,44 @@
package com.winlator.cmod.runtime.system;
+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.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 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;
import com.winlator.cmod.runtime.display.environment.XEnvironment;
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;
+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,9 +51,12 @@
* "swipe = close" behaviour.
*/
public class SessionKeepAliveService extends Service {
- private static final String TAG = "SessionKeepAlive";
- private static final String CHANNEL_ID = "winnative_session_keepalive";
+ 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";
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";
@@ -49,49 +64,142 @@ 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";
+
+ public static final String COMPONENT_STEAM = "Steam";
+ public static final String COMPONENT_EPIC = "Epic";
+ public static final String COMPONENT_GOG = "GOG";
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;
private static volatile boolean isContainerPaused = false;
+ // ── 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;
+ public static volatile boolean exitingFromNotification = false;
+ private BroadcastReceiver screenStateReceiver;
+
private PowerManager.WakeLock wakeLock;
- private WifiManager.WifiLock wifiLock;
- private int notificationId;
- 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
- }
- }
- };
+ // 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";
+ private static boolean isActivityVisible = false;
+
+ // Tracks active components and their notification messages
+ private static final Map activeComponents = new ConcurrentHashMap<>();
+
+ // ===================================================================
+ // Container / game session lifecycle
+ // ===================================================================
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);
- sendCommand(ctx, ACTION_SESSION_START, null);
+ isContainerPaused = false;
+ isActivityVisible = true;
+ exitingFromNotification = false;
+ LogManager.log(TAG, "startSession", ctx);
+ updateForegroundState(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);
+ if (instance != null) {
+ instance.acquireWakeLock();
+// instance.runOomSweep();
+ instance.startHeartbeat();
+ }
+ updateForegroundState(ctx);
+ }
+
+ public static void onResumeSession(Context ctx) {
+ if (ctx == null) return;
+ 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);
+ if (instance != null) {
+ instance.stopHeartbeat();
+ instance.releaseWakeLock();
+ }
+ updateForegroundState(ctx);
}
public static void stopSession(Context ctx) {
if (ctx == null) return;
- if (sessionActive.compareAndSet(true, false)) {
- sendCommand(ctx, ACTION_SESSION_STOP, null);
+ if (!sessionActive.compareAndSet(true, false)) return;
+ isContainerPaused = false;
+// LogManager.log(ctx, TAG, "stopSession");
+ 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);
+ }
+
+ public static boolean isSessionActive() {
+ return sessionActive.get();
+ }
+
+ // 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 +218,43 @@ 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 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 boolean isAppInBackground() { return isAppInBackground; }
+ public static boolean isDeviceLocked() { return isScreenLocked; }
+
+ public static boolean isAppNotVisible() {
+ return isAppInBackground || isScreenLocked;
+ }
+
+ // ===================================================================
+ // 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);
}
}
@@ -137,286 +262,475 @@ 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);
}
}
- public static boolean isSessionActive() {
- return sessionActive.get();
- }
+ // ===================================================================
+ // Foreground validation logic
+ // ===================================================================
private static boolean hasReason() {
- if (sessionActive.get()) return true;
- synchronized (activeDownloads) {
- return !activeDownloads.isEmpty();
- }
+ return sessionActive.get() || !activeDownloads.isEmpty() || !activeComponents.isEmpty() ||
+ (isAppNotVisible() && PrefManager.INSTANCE.getChatStayRunningOnExit());
}
- 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
- (ACTION_SESSION_PAUSE.equals(action) || ACTION_SESSION_START.equals(action) || ACTION_DL_START.equals(action))) {
- app.startForegroundService(intent);
+ // 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 && !serviceStopping) {
+ svc.ensureForeground();
} 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);
+ 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);
+ }
}
- Log.w(TAG, "Failed to send command " + action, e);
+ } 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();
}
}
+ // ===================================================================
+ // Foreground class logic
+ // ===================================================================
+
@Override
public void onCreate() {
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");
-// wakeLock.acquire();
+ 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);
}
- // 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;
+ // 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);
}
- wifiLock = wm.createWifiLock(lockType, "WinNative:WifiKeepAlive");
- }
+ };
- ensureChannel();
+ 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;
- 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;
- } else if (ACTION_SESSION_RESUME.equals(action)) {
- isContainerPaused = false;
- }
- 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) {
- Log.e(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);
- }
-
// 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;
+ 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;
+ }
+
if (!hasReason()) {
- Log.d(TAG, "No active reason; stopping keep-alive service");
+ Timber.tag(TAG).d("onStartCommand found no active reason; stopping immediately");
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 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());
+
+ // Determine target activity: Game screen if active, else Main menu
+ Class> targetActivity = containerActive ? XServerDisplayActivity.class : UnifiedActivity.class;
+
+ Notification n = notificationHelper.createForegroundNotification(
+ getNotificationContent(),
+ "WinNative",
+ SessionKeepAliveService.class,
+ showExit ? ACTION_SESSION_STOP : null, // Exit only for backgrounded app
+ 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 {
- 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);
+ // 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 {
- startForeground(notificationId, n);
+ // Standard notification update
+ notificationHelper.notify(notificationId, n);
}
} catch (Exception e) {
- Log.w(TAG, "Failed to startForeground", e);
+ LogManager.logW(TAG, "Failed to startForeground", e, this);
}
}
private void stopForegroundCompat() {
try {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- stopForeground(STOP_FOREGROUND_REMOVE);
- } else {
- stopForeground(true);
- }
+ stopForeground(STOP_FOREGROUND_REMOVE);
} catch (Exception e) {
- Log.w(TAG, "Failed to stopForeground", e);
+ LogManager.logW(TAG, "Failed to stopForeground", e, this);
}
}
- private 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);
- }
-
- 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";
- }
+ @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.");
- 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();
+ // Stop the service and cleanup
+ sessionActive.set(false);
+ isContainerPaused = false;
+ stopForegroundCompat();
+ stopSelf();
}
+ // ===================================================================
+ // 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);
+ resetLocalState();
+
+ performDefensiveCleanupAndExit(this);
+ }
+
+ @Override
+ public void onDestroy() {
+ serviceStopping = false;
+ if (wakeLock != null && wakeLock.isHeld()) wakeLock.release();
+// if (wifiLock != null && wifiLock.isHeld()) wifiLock.release();
+ stopHeartbeat();
+ releaseWakeLock();
+
+ androidx.lifecycle.ProcessLifecycleOwner.get()
+ .getLifecycle()
+ .removeObserver(appLifecycleObserver);
+
+ if (screenStateReceiver != null) {
+ try { unregisterReceiver(screenStateReceiver); } catch (Exception ignored) {}
+ screenStateReceiver = null;
+ }
+
+ notificationHelper.cancel(notificationId);
+ serviceRunning.set(false);
+ instance = null;
+ super.onDestroy();
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ // ===================================================================
+ // 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) {
+ LogManager.logE(TAG, "OOM protection sweep failed", e, this);
+ }
+ }
+
+ @NonNull
+ private static String getNotificationContent() {
+ // 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);
+ }
+
+ // 2. MEDIUM PRIORITY: Downloads
synchronized (activeDownloads) {
- activeDownloads.clear();
+ 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() && isAppNotVisible())
+ return instance.getString(R.string.fg_keep_alive_notification_content_steam_chat_running);
+
+ // 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());
+ 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) + ' ' + instance.getString(R.string.general_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(", " + instance.getString(R.string.general_and) + ' ');
+ }
+ }
+ 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);
+ return joinedNames + suffix;
}
+ 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 behaves like the
+ // 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);
- 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);
- } catch (Throwable t) {
- Log.w(TAG, "Task removal Steam cleanup failed", t);
- }
- }
- ProcessHelper.terminateSessionProcessesAndWait(1500, true);
- ProcessHelper.drainDeadChildren("session keep-alive task removed");
+ cleanUpSession(ctx, "session keep-alive shutdown");
} catch (Throwable t) {
- Log.w(TAG, "Defensive wine cleanup on task removal failed", t);
+ LogManager.logW(TAG, "Defensive cleanup failed", t, ctx);
}
+
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);
+ if (instance != null) {
+ instance.stopForegroundCompat();
+ instance.stopSelf();
+ }
+ // Final kill
+ new Handler(Looper.getMainLooper()).postDelayed(
+ () -> android.os.Process.killProcess(android.os.Process.myPid()), 500L);
});
- }, "SessionKeepAliveCleanup").start();
+ }, "SessionCleanupAndExit").start();
}
- @Override
- public void onDestroy() {
- protectionHandler.removeCallbacks(protectionRunnable);
- if (wakeLock != null && wakeLock.isHeld()) wakeLock.release();
- if (wifiLock != null && wifiLock.isHeld()) wifiLock.release();
- serviceRunning.set(false);
- super.onDestroy();
+ 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);
}
- @Nullable
- @Override
- public IBinder onBind(Intent intent) {
- return null;
+ public static void stopAll(Context ctx) {
+ stopSession(ctx);
+ resetLocalState();
+
+ // 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();
+ }
}
- private void 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
+ // 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 25bf7c9b9..e2b949222 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,52 +23,46 @@ 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
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 ACTION_EXIT = BuildConfig.APPLICATION_ID + ".EXIT"
const val EXTRA_OPEN_CHAT_FRIEND_ID = BuildConfig.APPLICATION_ID + ".OPEN_CHAT_FRIEND_ID"
+
+ const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".EXIT"
}
private val notificationManager: NotificationManager =
context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
init {
+ // Default channel
createNotificationChannel()
- }
-
- 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(
- CHAT_CHANNEL_ID,
- CHAT_CHANNEL_NAME,
- NotificationManager.IMPORTANCE_HIGH,
- ).apply {
- description = "Incoming Steam friend messages"
- setShowBadge(true)
- }
-
- notificationManager.createNotificationChannel(chatChannel)
-
- val backgroundChannel =
+ // Steam chat channel
+ createNotificationChannel(
+ context.applicationContext,
+ CHAT_CHANNEL_ID,
+ CHAT_CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_HIGH,
+ "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,
@@ -80,131 +74,173 @@ class NotificationHelper
enableVibration(false)
}
- notificationManager.createNotificationChannel(backgroundChannel)
+ notificationManager.createNotificationChannel(backgroundChannel)*/
}
- private fun chatNotificationId(friendId: Long): Int = 2_000_000 + ((friendId.hashCode() and 0x7FFFFFFF) % 1_000_000)
+ // 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)
+ }
- 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)
- }
+ // Overload to notify using a pre-built Notification object.
+ fun notify(id: Int, notification: Notification) {
+ notificationManager.notify(id, notification)
+ }
- fun cancelChatNotification(friendId: Long) {
- notificationManager.cancel(chatNotificationId(friendId))
- }
+ 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 cancelBackgroundRunning() {
- notificationManager.cancel(BACKGROUND_RUNNING_NOTIFICATION_ID)
- }
+ return builder.build()
+ }
- 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
+ /**
+ * 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
}
- 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,
- )
-
- 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()
+ nm.createNotificationChannel(channel)
}
+ }
- 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,
- )
- return NotificationCompat
- .Builder(context, CHAT_BG_CHANNEL_ID)
- .setContentTitle(context.getString(R.string.common_ui_app_name))
- .setContentText("Steam chat running in background")
+ /**
+ * Overload of createNotificationChannel()
+ * without description.
+ */
+ fun createNotificationChannel(
+ context: Context,
+ channelId: String?,
+ name: String?,
+ importance: Int
+ ) {
+ createNotificationChannel(context, channelId, name, importance, "", false)
+ }
+
+ /**
+ * Overload of createNotificationChannel()
+ * using default values.
+ */
+ fun createNotificationChannel() {
+ createNotificationChannel(
+ context,
+ CHANNEL_ID,
+ CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_LOW,
+ "Allows to display WinNative foreground notifications",
+ false
+ )
+ }
+
+ /**
+ * 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
+ }
+
+ 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_DEFAULT)
- .setOnlyAlertOnce(true)
- .setAutoCancel(false)
- .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
+ .setAutoCancel(true)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setContentIntent(pendingIntent)
- .addAction(0, "Exit", stopPendingIntent)
.build()
- }
+ notificationManager.notify(chatNotificationId(friendId), notification)
+ }
+
+ fun cancelChatNotification(friendId: Long) {
+ notificationManager.cancel(chatNotificationId(friendId))
}
+}
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"
+ }
+}
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" }