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/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt
index e94f5775d..cf6d12d68 100644
--- a/app/src/main/app/shell/UnifiedActivity.kt
+++ b/app/src/main/app/shell/UnifiedActivity.kt
@@ -237,12 +237,14 @@ import com.winlator.cmod.shared.theme.WinNativeTheme
import dagger.hilt.android.AndroidEntryPoint
import dagger.Lazy
import com.winlator.cmod.feature.stores.steam.enums.EPersonaState
+import com.winlator.cmod.runtime.system.LogManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import timber.log.Timber
import javax.inject.Inject
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -1511,6 +1513,17 @@ class UnifiedActivity :
override fun onCreate(savedInstanceState: Bundle?) {
instance = this
super.onCreate(savedInstanceState)
+
+ // Initialize Timber in debug builds for logging.
+ // If 'BuildConfig' give error, ignore it.
+ // The file needed will be autogenerated when building the apk.
+ if (com.winlator.cmod.BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree());
+ }
+
+ // Initialize the LogManager context in case a fallback is needed.
+ LogManager.init(this)
+
if (!SetupWizardActivity.isSetupComplete(this) || !ImageFs.find(this).isUpToDate) {
startActivity(
Intent(this, SetupWizardActivity::class.java)
diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt
index 76c867d8f..3c32a53b5 100644
--- a/app/src/main/feature/settings/debug/DebugFragment.kt
+++ b/app/src/main/feature/settings/debug/DebugFragment.kt
@@ -8,11 +8,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 +25,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 +84,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 +240,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..ba698db72 100644
--- a/app/src/main/feature/settings/debug/DebugScreen.kt
+++ b/app/src/main/feature/settings/debug/DebugScreen.kt
@@ -6,8 +6,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 +133,15 @@ import com.winlator.cmod.shared.ui.toast.WinToast
import com.winlator.cmod.shared.ui.outlinedSwitchColors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.input.ImeAction
+
+import com.winlator.cmod.runtime.system.LogManager
// Palette (mirrors StoresScreen)
private val BgDark = Color(0xFF11111C)
@@ -148,6 +159,13 @@ private val TextSecondary = Color(0xFF7A8FA8)
// State
data class DebugState(
val appDebug: Boolean = false,
+ val exitReasonLog: Boolean = false,
+ val crashLog: Boolean = false,
+ val eventWatchLog: Boolean = false,
+ val tagFilterMode: LogManager.TagFilterMode = LogManager.TagFilterMode.ALL,
+ val selectedTags: List = emptyList(),
+ val customTags: List = emptyList(),
+
val wineDebug: Boolean = false,
val wineChannels: List = emptyList(),
val wineClasses: List = emptyList(),
@@ -175,7 +193,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 +226,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 +246,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 +307,72 @@ fun DebugScreen(
onCheckedChange = onAppDebugChanged,
)
+ SettingsToggleCard(
+ title = stringResource(R.string.settings_debug_exit_reason_log_title),
+ subtitle = stringResource(R.string.settings_debug_exit_reason_log_subtitle),
+ icon = Icons.Outlined.BugReport,
+ checked = state.exitReasonLog,
+ onCheckedChange = onExitReasonLogChanged,
+ )
+
+ 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 +460,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 +677,7 @@ private fun WineChannelsCard(
}
}
-// Wine debug message-class card (err / warn / fixme / trace)
+// Wine debug message-class card (err / warn / fix-me / trace)
@Composable
private fun WineClassesCard(
options: List,
@@ -672,6 +786,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 +997,16 @@ private fun SmallActionButton(
}
}
-// Wine debug channel selector dialog
+// Generic MultiSelectDialog
@Composable
-private fun WineChannelsDialog(
+private fun MultiSelectDialog(
+ title: String,
options: List,
initiallySelected: List,
onDismiss: () -> Unit,
onConfirm: (List) -> Unit,
+ extraContent: (@Composable (selected: Set, onToggle: (String) -> Unit) -> Unit)? = null,
+ systemTags: Set = emptySet(),
) {
val selected =
remember(initiallySelected) {
@@ -859,21 +1101,18 @@ 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,
- )
+ // 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 +1129,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 +1166,202 @@ 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) },
+ extraContent = { selectedSet, onToggle ->
+ Column {
+ // Mode switch row
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(text = stringResource(R.string.settings_debug_tag_filter_mode), color = TextPrimary)
+ Spacer(Modifier.width(8.dp))
+ SegmentedControl(
+ options = listOf(
+ stringResource(R.string.settings_debug_tag_filter_all),
+ stringResource(R.string.settings_debug_tag_filter_include),
+ stringResource(R.string.settings_debug_tag_filter_exclude),
+ ),
+ selectedIndex = when (mode) {
+ LogManager.TagFilterMode.ALL -> 0
+ LogManager.TagFilterMode.INCLUDE -> 1
+ LogManager.TagFilterMode.EXCLUDE -> 2
+ },
+ onSelectedIndex = { idx ->
+ mode = when (idx) {
+ 0 -> LogManager.TagFilterMode.ALL
+ 1 -> LogManager.TagFilterMode.INCLUDE
+ else -> LogManager.TagFilterMode.EXCLUDE
+ }
+ }
+ )
+ }
+
+ Spacer(Modifier.height(12.dp))
+
+ // Add custom tag field
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ OutlinedTextField(
+ value = newTagText,
+ onValueChange = { newTagText = it },
+ placeholder = { Text(stringResource(R.string.settings_debug_add_custom_tag_hint)) },
+ singleLine = true,
+ modifier = Modifier.weight(1f)
+ )
+ Spacer(Modifier.width(8.dp))
+ SmallActionButton(
+ label = stringResource(R.string.common_ui_add),
+ textColor = Accent,
+ onClick = {
+ val tag = newTagText.trim()
+ if (tag.isNotEmpty()) {
+ onAddCustomTag(tag)
+ newTagText = ""
+ }
+ }
+ )
+ }
+
+ // Show custom tags with remove affordance
+ if (customTags.isNotEmpty()) {
+ Spacer(Modifier.height(8.dp))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ customTags.forEach { tag ->
+ RemovableTagChip(tag = tag, onRemove = { onRemoveCustomTag(tag) })
+ }
+ }
+ }
+
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+ )
+}
+
+@Composable
+private fun SegmentedControl(
+ options: List,
+ selectedIndex: Int,
+ onSelectedIndex: (Int) -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .clip(RoundedCornerShape(10.dp))
+ .background(SurfaceDark)
+ .border(1.dp, CardBorder, RoundedCornerShape(10.dp))
+ .padding(2.dp),
+ ) {
+ options.forEachIndexed { index, label ->
+ val isSelected = index == selectedIndex
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(if (isSelected) Accent else Color.Transparent)
+ // 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 +1369,7 @@ private fun ColumnScope.ChannelGrid(
gridState: androidx.compose.foundation.lazy.grid.LazyGridState,
onViewport: (Float, Int) -> Unit,
onToggle: (String) -> Unit,
+ systemTags: Set = emptySet(), // new — tags that render in a distinct color
) {
if (options.isEmpty()) {
Text(
@@ -948,11 +1394,14 @@ private fun ColumnScope.ChannelGrid(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
itemsIndexed(options) { index, channel ->
+ val isSystem = channel in systemTags
+ val chipAccent = if (isSystem) Color(0xFFFFCC00) else Accent
SelectableChannelChip(
label = channel,
isSelected = channel in selected,
isEntry = index == 0,
onToggle = { onToggle(channel) },
+ tint = chipAccent,
)
}
}
@@ -964,10 +1413,11 @@ private fun SelectableChannelChip(
isSelected: Boolean,
isEntry: Boolean,
onToggle: () -> Unit,
+ tint: Color = Accent,
) {
- val bg = if (isSelected) Accent.copy(alpha = 0.18f) else IconBoxBg
- val borderColor = if (isSelected) Accent.copy(alpha = 0.55f) else CardBorder
- val textColor = if (isSelected) Accent else TextPrimary
+ val bg = if (isSelected) tint.copy(alpha = 0.18f) else IconBoxBg
+ val borderColor = if (isSelected) tint.copy(alpha = 0.55f) else CardBorder
+ val textColor = if (isSelected) tint else TextPrimary
Box(
modifier =
Modifier
diff --git a/app/src/main/feature/settings/other/OtherSettingsFragment.kt b/app/src/main/feature/settings/other/OtherSettingsFragment.kt
index 6ea11acc1..07e95d844 100644
--- a/app/src/main/feature/settings/other/OtherSettingsFragment.kt
+++ b/app/src/main/feature/settings/other/OtherSettingsFragment.kt
@@ -13,7 +13,6 @@ import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -32,6 +31,7 @@ import com.winlator.cmod.feature.shortcuts.FrontendExporter
import com.winlator.cmod.feature.setup.SetupWizardActivity
import com.winlator.cmod.runtime.audio.midi.MidiManager
import com.winlator.cmod.runtime.display.environment.ImageFsInstaller
+import com.winlator.cmod.runtime.system.ProcessHelper
import com.winlator.cmod.shared.ui.toast.WinToast
import com.winlator.cmod.shared.android.DirectoryPickerDialog
import com.winlator.cmod.shared.android.LocaleHelper
@@ -168,6 +168,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()
@@ -235,7 +252,13 @@ class OtherSettingsFragment : Fragment() {
enableFileProvider = preferences.getBoolean("enable_file_provider", true),
openInBrowser = preferences.getBoolean("open_with_android_browser", false),
shareClipboard = preferences.getBoolean("share_android_clipboard", false),
- enableBackgroundSession = preferences.getBoolean("enable_background_session", false),
+ enableBackgroundSession = preferences.getBoolean("enable_background_session", true),
+ enableAutoPause = preferences.getBoolean("enable_auto_pause_when_background", false),
+ useBackgroundWakelock = preferences.getBoolean("enable_background_wakelock", false),
+ 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..795aaadea 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,6 +287,54 @@ fun OtherSettingsScreen(
onCheckedChange = onXinputDisabledChanged,
)
+ SectionLabel(stringResource(R.string.settings_other_section_background), modifier = Modifier.padding(top = 8.dp))
+
+ SettingsToggleCard(
+ title = stringResource(R.string.settings_general_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,
+ )
+
SettingsToggleCard(
title = stringResource(R.string.session_drawer_output_to_display),
subtitle = stringResource(R.string.settings_external_display_output_summary),
@@ -269,14 +345,6 @@ fun OtherSettingsScreen(
SectionLabel(stringResource(R.string.settings_other_section_integration), modifier = Modifier.padding(top = 8.dp))
- SettingsToggleCard(
- title = stringResource(R.string.settings_general_background),
- subtitle = "Keep session alive while in background",
- icon = Icons.Outlined.Visibility,
- checked = state.enableBackgroundSession,
- onCheckedChange = onEnableBackgroundSessionChanged,
- )
-
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,253 @@ 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,
+) {
+ // Raw text while the user is typing; committed as Int on Done/focus-loss.
+ var rawText by remember(currentFrequency) { mutableStateOf(currentFrequency.toString()) }
+ val focusManager = LocalFocusManager.current
+
+ // Derive the effective value and whether the current input is in error,
+ // so we can give the user immediate feedback without committing bad state.
+ val parsed = rawText.trim().toIntOrNull()
+ val isError = parsed == null || (parsed != 0 && parsed < 5)
+ val effectiveLabel = when {
+ parsed == null -> stringResource(R.string.settings_other_bg_heartbeat_disabled)
+ parsed == 0 -> stringResource(R.string.settings_other_bg_heartbeat_disabled)
+ parsed < 5 -> stringResource(R.string.settings_other_bg_heartbeat_minimum)
+ else -> stringResource(R.string.settings_other_bg_heartbeat_effective, parsed)
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(CardDark)
+ .border(1.dp, CardBorder, RoundedCornerShape(12.dp)),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .size(34.dp)
+ .clip(RoundedCornerShape(9.dp))
+ .background(IconBoxBg),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Timer,
+ contentDescription = null,
+ tint = Accent,
+ modifier = Modifier.size(17.dp),
+ )
+ }
+ Spacer(Modifier.width(13.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.settings_other_bg_heartbeat_title),
+ color = TextPrimary,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Text(
+ text = stringResource(R.string.settings_other_bg_heartbeat_subtitle),
+ color = TextSecondary,
+ fontSize = 11.sp,
+ )
+ }
+ }
+ Spacer(Modifier.height(10.dp))
+ OutlinedTextField(
+ value = rawText,
+ onValueChange = { rawText = it.filter { c -> c.isDigit() } },
+ singleLine = true,
+ isError = isError,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ commitFrequency(parsed, onFrequencyChanged)
+ },
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .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 db4087025..70eb4ffb3 100644
--- a/app/src/main/feature/setup/SetupWizardActivity.kt
+++ b/app/src/main/feature/setup/SetupWizardActivity.kt
@@ -139,6 +139,7 @@ import java.io.File
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
+import androidx.core.content.edit
private data class Particle(
val x: Float,
@@ -591,7 +592,7 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() {
notifDenied.value = !granted
if (granted) {
backgroundSessionEnabled.value = true
- prefs(this).edit().putBoolean("enable_background_session", true).apply()
+ prefs(this).edit { putBoolean("enable_background_session", true) }
} else if (Build.VERSION.SDK_INT >= 33 &&
!shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)
) {
@@ -888,7 +889,12 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() {
storageGranted.value = hasStoragePermission()
notifGranted.value = hasNotificationPermissionSilently()
- backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", false)
+ backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", true)
+ if (Build.VERSION.SDK_INT > 36) {
+ if (!prefs(this).contains("enable_background_wakelock")) { // If wakeLock preference isn't saved, enable it by default on Android 16+.
+ prefs(this).edit { putBoolean("enable_background_wakelock", true) }
+ }
+ }
refreshWizardState()
loadAdvancedProfiles()
@@ -930,7 +936,7 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() {
if (notificationsEnabled) {
notifDenied.value = false
}
- backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", false)
+ backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", true)
refreshWizardState()
refreshRecommendedPackageCache()
}
diff --git a/app/src/main/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 665e65695..d1f7a958b 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()
@@ -6935,8 +6927,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")
}
@@ -6961,12 +6957,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 {
@@ -6985,8 +6983,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 {
@@ -7546,8 +7547,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)
@@ -7652,6 +7658,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
}
@@ -7668,9 +7679,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() }
@@ -7730,13 +7744,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")
@@ -7753,7 +7780,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,
@@ -7761,7 +7794,8 @@ class SteamService : Service() {
)
notificationHelper.cancel()
}.onFailure { Timber.w(it, "Failed to show Steam background-chat notification") }
- }
+ }*/
+ // ToDo end.
scheduleBackgroundSuspendCheck()
}
@@ -7786,7 +7820,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
@@ -7810,12 +7846,14 @@ class SteamService : Service() {
picsGetProductInfoJob?.cancel()
messagePollerJob?.cancel()
wnSession?.let { s -> runCatching { s.disconnect() } }
- scope.launch(Dispatchers.Main) {
+ // ToDo: Check this Steam Friends code:
+ /*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.
return true
}
@@ -7994,6 +8032,7 @@ class SteamService : Service() {
runCatching { s.close() }
}
wnSession = null
+ SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM)
clearValues()
}
@@ -8017,6 +8056,8 @@ class SteamService : Service() {
_unifiedFriends?.close()
_unifiedFriends = null
+ familyGroupMembers.clear()
+
isStopping = false
retryAttempt = 0
reconnectJob?.cancel()
@@ -8030,8 +8071,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
@@ -8061,7 +8106,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 =
@@ -8174,7 +8219,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))
@@ -9041,4 +9088,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-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 3db58b93d..65e546683 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -1520,6 +1520,23 @@ Installierter Pfad:
Benutzerdefinierter Wert
Zum Zielen neigen (Ausrichtung)
+
+ Grund-für-Beendigung-Protokoll
+ Erfasst, warum der App-Prozess beendet wurde
+ Absturzprotokoll
+ Native Crash-Tombstone-Spuren speichern
+ Ereignisüberwachungsprotokoll
+ Fokussiertes Systemprotokoll um Pause/Fortsetzen-Ereignisse erfassen
+ Protokoll-Tag-Filter
+ Tags ausgewählt
+ Protokollzeilen nach Text filtern…
+ Modus
+ Alle
+ Einschließen
+ Ausschließen
+ Benutzerdefinierten Tag hinzufügen…
+ Keine Tags ausgewählt
+
Dateien
Kopieren
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index f173826f6..5a60727fb 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -1519,6 +1519,24 @@ Ruta instalada:
Traer al frente
Valor personalizado
Inclinar para apuntar (orientación)
+
+
+ Registro de razón de salida
+ Registra por qué terminó el proceso de la aplicación
+ Registro de fallos
+ Guardar registros del último crash
+ Registro de observación de eventos
+ Capturar un registro del sistema centrado alrededor de eventos de pausa/reanudación
+ Filtro de etiqueta de registro
+ etiquetas seleccionadas
+ Filtrar líneas de registro por texto…
+ Modo
+ Todas
+ Incluir
+ Excluir
+ Añadir etiqueta personalizada…
+ Ninguna etiqueta seleccionada
+
Archivos
Copiar
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 15d7a9801..c185d9b41 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -1519,6 +1519,24 @@ Chemin installé :
Mettre au premier plan
Valeur personnalisée
Incliner pour viser (orientation)
+
+
+ Journal des raisons de sortie
+ Enregistre pourquoi le processus de l\'application s\'est terminé
+ Journal des plantages
+ Enregistrer les traces de tombstones de plantages natifs
+ Journal de suivi des événements
+ Capturer un journal système centré autour des événements de pause/reprise
+ Filtre d\'étiquettes de journal
+ étiquettes sélectionnées
+ Filtrer les lignes du journal par texte…
+ Mode
+ Tous
+ Inclure
+ Exclure
+ Ajouter une étiquette personnalisée…
+ Aucune étiquette sélectionnée
+
Fichiers
Copier
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 8ce77f6ba..08d3aeeb5 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -1519,6 +1519,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
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index faae17665..4cb077269 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
@@ -1122,6 +1137,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
diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java
index cc095b365..10ccff79d 100644
--- a/app/src/main/runtime/display/XServerDisplayActivity.java
+++ b/app/src/main/runtime/display/XServerDisplayActivity.java
@@ -15,7 +15,6 @@
import android.hardware.SensorManager;
import android.hardware.input.InputManager;
import android.net.Uri;
-import android.opengl.GLSurfaceView;
import android.text.format.DateFormat;
import android.os.Build;
import android.os.Bundle;
@@ -28,11 +27,7 @@
import android.view.PointerIcon;
import android.view.View;
import android.view.ViewGroup;
-import android.widget.AdapterView;
-import android.widget.ArrayAdapter;
-import android.widget.CheckBox;
import android.widget.FrameLayout;
-import android.widget.Spinner;
import android.widget.TextView;
import org.json.JSONException;
@@ -78,6 +73,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;
@@ -162,10 +158,6 @@
import com.winlator.cmod.runtime.display.xserver.WindowManager;
import com.winlator.cmod.runtime.display.xserver.XServer;
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
@@ -179,10 +171,10 @@
import java.util.List;
import java.util.HashSet;
import java.util.HashMap;
-import java.util.Iterator;
import java.util.Locale;
import java.util.Timer;
import java.util.TimerTask;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.concurrent.CountDownLatch;
@@ -191,6 +183,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 +479,9 @@ private boolean isAnyControllerConnected() {
private boolean isDarkMode;
private boolean enableLogsMenu;
+ private boolean autoPauseContainer;
+ private static final String TAG = "XServerDisplayActivity";
+ private static final AtomicLong breadcrumbCounter = new AtomicLong(0);
private GuestProgramLauncherComponent guestProgramLauncherComponent;
private EnvVars overrideEnvVars;
@@ -797,7 +793,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);
}
}
@@ -889,7 +885,7 @@ private void handleDebugInjectTap(Intent intent) {
private void switchLaunchTargetAfterCleanup(Intent intent) {
if (!switchLaunchInProgress.compareAndSet(false, true)) {
- Log.d("XServerDisplayActivity", "Switch launch already in progress; ignoring duplicate target intent");
+ LogManager.log("XServerDisplayActivity", "Switch launch already in progress; ignoring duplicate target intent", this);
return;
}
@@ -906,7 +902,7 @@ private void switchLaunchTargetAfterCleanup(Intent intent) {
performForcedSessionCleanup("switch launch target");
runOnUiThread(() -> {
if (isFinishing() || isDestroyed()) {
- Log.w("XServerDisplayActivity", "Switch cleanup finished after activity was destroyed");
+ LogManager.logW("XServerDisplayActivity", "Switch cleanup finished after activity was destroyed", null, this);
return;
}
setIntent(relaunchIntent);
@@ -922,7 +918,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(
@@ -946,10 +942,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);
@@ -971,7 +973,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);
@@ -981,6 +983,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);
@@ -1033,7 +1036,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.");
}
};
@@ -1143,7 +1146,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);
}
}
@@ -1215,13 +1218,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;
}
@@ -1329,12 +1332,12 @@ public void handleOnBackPressed() {
if (newContainerId != container.id) {
container = containerManager.getContainerById(newContainerId);
if (container == null) {
- Log.e("XServerDisplayActivity", "Failed to retrieve overridden container with ID: " + newContainerId);
+ LogManager.logE("XServerDisplayActivity", "Failed to retrieve overridden container with ID: " + newContainerId, null, this);
finish();
return;
}
if (!containerManager.activateContainer(container)) {
- Log.e("XServerDisplayActivity", "Failed to activate overridden container with ID: " + newContainerId);
+ LogManager.logE("XServerDisplayActivity", "Failed to activate overridden container with ID: " + newContainerId, null, this);
finish();
return;
}
@@ -1540,7 +1543,7 @@ public void handleOnBackPressed() {
cachedPreloaderSubtitle = container != null ? container.getName() : "";
showLaunchPreloader(getString(R.string.preloader_initializing));
- if (preferences.getBoolean("enable_background_session", false)) {
+ if (preferences.getBoolean("enable_background_session", true)) {
SessionKeepAliveService.startSession(this);
}
@@ -2385,8 +2388,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) {
@@ -2394,10 +2396,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;
@@ -2409,6 +2420,10 @@ public void onPause() {
boolean cleaningUp = exitRequested.get() || sessionCleanupStarted.get() || activityDestroyed.get();
+ if (!cleaningUp) {
+ if (autoPauseContainer) ProcessHelper.pauseAllWineProcesses();
+ }
+
if (!cleaningUp && !isInPictureInPictureMode()) {
if (environment != null) {
environment.onPause();
@@ -2559,12 +2574,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 {
@@ -2597,18 +2612,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;
}
@@ -2618,12 +2633,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");
}
@@ -2637,29 +2652,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();
@@ -2668,15 +2683,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;
}
@@ -2743,14 +2758,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);
}
}
@@ -2910,13 +2925,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) {
@@ -2955,8 +2970,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 {
@@ -2998,11 +3014,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;
}
@@ -3036,8 +3052,8 @@ private void exit() {
environment.stopEnvironmentComponents();
environment = null;
}
- Log.d("XServerDisplayActivity", "Process snapshot after environment stop: "
- + ProcessHelper.listRunningWineProcessDetails());
+ LogManager.log("XServerDisplayActivity", "Process snapshot after environment stop: "
+ + ProcessHelper.listRunningWineProcessDetails(), this);
stopXServer("exit");
wineRequestHandler = null;
midiHandler = null;
@@ -3060,13 +3076,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();
}
@@ -3791,6 +3836,7 @@ protected void onDestroy() {
if (exitRequested.get()) {
SessionKeepAliveService.stopSession(this);
}
+ LogManager.stopEventWatch();
super.onDestroy();
if (!switchLaunchInProgress.get()) {
@@ -3798,7 +3844,7 @@ protected void onDestroy() {
}
if (!sessionCleanupStarted.get()) {
- if (exitRequested.get() || !preferences.getBoolean("enable_background_session", false)) {
+ if (exitRequested.get() || !preferences.getBoolean("enable_background_session", true)) {
performForcedSessionCleanup("onDestroy");
}
}
@@ -3817,7 +3863,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");
@@ -5457,8 +5503,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();
}
@@ -6446,6 +6492,7 @@ private static boolean safeEquals(String a, String b) {
return a != null && a.equals(b);
}
+ // ToDo: Test this disabled as suspect of being related to container auto shut down.
private void setupXEnvironment() throws PackageManager.NameNotFoundException {
if (SessionKeepAliveService.isSessionActive()) {
XEnvironment existingEnv = SessionKeepAliveService.getActiveEnvironment();
@@ -6859,7 +6906,8 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException {
guestProgramLauncherComponent.setEnvVars(envVars);
guestProgramLauncherComponent.setTerminationCallback((status) -> {
- Log.d("XServerDisplayActivity", "Guest process terminated with status: " + 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) {
@@ -7985,8 +8033,6 @@ public InputControlsView getInputControlsView() {
return inputControlsView;
}
- private static final String TAG = "DXWrapperExtraction";
-
private static final String[] DXWRAPPER_DLLS = {
"d3d10.dll", "d3d10_1.dll", "d3d10core.dll",
"d3d11.dll", "d3d12.dll", "d3d12core.dll",
diff --git a/app/src/main/runtime/display/connector/Client.java b/app/src/main/runtime/display/connector/Client.java
index 983b67bbf..888cfeaf6 100644
--- a/app/src/main/runtime/display/connector/Client.java
+++ b/app/src/main/runtime/display/connector/Client.java
@@ -3,6 +3,7 @@
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
+import java.util.concurrent.atomic.AtomicBoolean;
public class Client {
public final ClientSocket clientSocket;
@@ -12,13 +13,25 @@ public class Client {
private Object tag;
protected Thread pollThread;
protected int shutdownFd;
- protected boolean connected;
+ protected volatile boolean connected; // was a plain boolean; read/written cross-thread
+
+ // Guards killConnection(): both this client's own poll thread (on EOF/IOException)
+ // and the connector's teardown thread (XConnectorEpoll.shutdown()) can call
+ // killConnection() for the same client concurrently. Without a single-entry
+ // gate, both run the full close sequence, double-closing shutdownFd/clientSocket.fd
+ // after the OS has already reassigned the number elsewhere (fdsan abort).
+ private final AtomicBoolean killing = new AtomicBoolean(false);
public Client(XConnectorEpoll connector, ClientSocket clientSocket) {
this.connector = connector;
this.clientSocket = clientSocket;
}
+ /** True the first time this is called for this client; false on every call after. */
+ protected boolean markKillingOnce() {
+ return killing.compareAndSet(false, true);
+ }
+
public void createIOStreams() {
if (inputStream != null || outputStream != null) return;
inputStream = new XInputStream(clientSocket, connector.getInitialInputBufferCapacity());
diff --git a/app/src/main/runtime/display/connector/XConnectorEpoll.java b/app/src/main/runtime/display/connector/XConnectorEpoll.java
index b794e761b..5975f63fa 100644
--- a/app/src/main/runtime/display/connector/XConnectorEpoll.java
+++ b/app/src/main/runtime/display/connector/XConnectorEpoll.java
@@ -2,6 +2,9 @@
import android.util.SparseArray;
import androidx.annotation.Keep;
+
+import com.winlator.cmod.runtime.system.LogManager;
+
import java.io.IOException;
import java.nio.ByteBuffer;
@@ -19,6 +22,8 @@ public class XConnectorEpoll implements Runnable {
private int initialOutputBufferCapacity = 4096;
private final SparseArray connectedClients = new SparseArray<>();
+ private String TAG = "XConnectorEpoll";
+
static {
System.loadLibrary("winlator");
}
@@ -123,10 +128,10 @@ private void handleExistingConnection(int fd) {
while (running && requestHandler.handleRequest(client))
activePosition = inputStream.getActivePosition();
inputStream.setActivePosition(activePosition);
- } else killConnection(client);
+ } else killConnection(client, "EOF on read"); // handleExistingConnection's readMoreData()<=0 branch
} else requestHandler.handleRequest(client);
} catch (IOException e) {
- killConnection(client);
+ killConnection(client, "IOException: " + e);
}
}
@@ -136,7 +141,18 @@ public Client getClient(int fd) {
}
}
- public void killConnection(Client client) {
+ public void killConnection(Client client, String reason) {
+ if (client == null) {
+ LogManager.logW(TAG, "killConnection called with null client; reason=" + reason);
+ return;
+ }
+
+ // Log immediately on entry: fd, reason, whether multithreadedClients
+ int fd = client.clientSocket != null ? client.clientSocket.fd : -1;
+ LogManager.logI(TAG,
+ "killConnection entry: fd=" + fd + " reason=\"" + reason + "\" multithreadedClients=" + multithreadedClients);
+
+ if (!client.markKillingOnce()) return; // already being/been torn down by another thread
client.connected = false;
connectionHandler.handleConnectionShutdown(client);
if (multithreadedClients) {
@@ -147,6 +163,11 @@ public void killConnection(Client client) {
try {
client.pollThread.join();
} catch (InterruptedException e) {
+ // Restore interrupt status and log interruption
+ Thread.currentThread().interrupt();
+ LogManager.logW(TAG,
+ "Interrupted while joining pollThread for fd=" + fd + " reason=\"" + reason + "\"");
+ break;
}
}
@@ -155,12 +176,27 @@ public void killConnection(Client client) {
closeFd(client.shutdownFd);
} else removeFdFromEpoll(epollFd, client.clientSocket.fd);
// Free direct byte buffers and ancillary FDs to avoid resource leak warnings
- client.releaseIOStreams();
- client.clientSocket.closeAncillaryFds();
+ try {
+ client.releaseIOStreams();
+ } catch (Exception e) {
+ LogManager.logW(TAG,
+ "Exception releasing IO streams for fd=" + fd + " reason=\"" + reason + "\"",
+ e);
+ }
+ try {
+ client.clientSocket.closeAncillaryFds();
+ } catch (Exception e) {
+ LogManager.logW(TAG,
+ "Exception closing ancillary FDs for fd=" + fd + " reason=\"" + reason + "\"",
+ e);
+ }
closeFd(client.clientSocket.fd);
synchronized (connectedClients) {
connectedClients.remove(client.clientSocket.fd);
}
+
+ LogManager.log(TAG,
+ "killConnection completed: fd=" + fd + " reason=\"" + reason + "\"");
}
private void shutdown() {
@@ -170,7 +206,7 @@ private void shutdown() {
if (connectedClients.size() == 0) break;
client = connectedClients.valueAt(connectedClients.size() - 1);
}
- killConnection(client);
+ killConnection(client, "connector shutdown");
}
removeFdFromEpoll(epollFd, serverFd);
diff --git a/app/src/main/runtime/display/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..9ffcc1dee 100644
--- a/app/src/main/runtime/system/LogManager.kt
+++ b/app/src/main/runtime/system/LogManager.kt
@@ -1,24 +1,215 @@
package com.winlator.cmod.runtime.system
+import android.Manifest
+import android.app.ActivityManager
+import android.app.ApplicationExitInfo
import android.content.Context
-import android.os.Environment
+import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
import android.util.Log
+import androidx.core.app.ActivityCompat
import androidx.preference.PreferenceManager
+import com.winlator.cmod.app.config.SettingsConfig
+import com.winlator.cmod.shared.io.FileUtils
+import timber.log.Timber
import java.io.Closeable
import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
object LogManager {
private const val TAG = "LogManager"
+ private const val APP_LOG_FILE = "app_debug.log"
+ private const val EXIT_REASONS_FILE = "exit_reasons.log"
+ private const val CRASH_FILE = "crash.log"
+
private var logcatProcess: Process? = null
private var appLogProcess: Process? = null
+ private var 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",
+ "WineOomProtect",
+ "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"
+
+ // ── 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
+
+ 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
+ }
+
+ // Capture previous exit reasons
+ logLastExitReasons(app)
+ }
+
+ 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 +217,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 +233,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 +246,74 @@ 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(",")).apply()
+ }
+
+ @JvmStatic
+ fun removeCustomTag(context: Context, tag: String) {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ val updatedCustomTags = cachedCustomTags - tag
+ val updatedSelectedTags = cachedSelectedTags - tag // deselect too — a removed tag can't stay selected
+ prefs.edit()
+ .putString(PREF_CUSTOM_TAGS, updatedCustomTags.joinToString(","))
+ .putString(PREF_SELECTED_TAGS, updatedSelectedTags.joinToString(","))
+ .apply()
+ }
+
+ @JvmStatic
+ fun setSelectedTags(context: Context, tags: Set) {
+ PreferenceManager.getDefaultSharedPreferences(context).edit()
+ .putString(PREF_SELECTED_TAGS, tags.joinToString(",")).apply()
+ }
+
+ @JvmStatic
+ fun getSelectedTags(): Set = cachedSelectedTags
+
+ @JvmStatic
+ fun setTagFilterMode(context: Context, mode: TagFilterMode) {
+ PreferenceManager.getDefaultSharedPreferences(context).edit()
+ .putString(PREF_TAG_FILTER_MODE, mode.name).apply()
+ }
+
+ @JvmStatic
+ fun getTagFilterMode(): TagFilterMode = cachedTagFilterMode
+
+ @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 +322,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 +333,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 +347,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 +370,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 +382,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}")
}
}
@@ -177,4 +430,464 @@ object LogManager {
/** Deletes all shareable log files; returns the count removed. */
@JvmStatic
fun deleteShareableLogs(context: Context): Int = getShareableLogFiles(context).count { it.delete() }
+
+ // ── 1. Custom breadcrumbs, callable from anywhere ───────────────
+ //
+ // Writes directly to disk (open → write → flush → close on every
+ // call) instead of going through a buffered writer. This is
+ // deliberate: if the process gets killed seconds after this call,
+ // an open-but-unflushed buffer would lose exactly the line need.
+ // A few extra file opens per session is a non-issue.
+ //
+ // Message arguments are still evaluated eagerly by the caller
+ // for the plain String overloads — for anything expensive to build,
+ // guard it with `if (LogManager.isDebugEnabled)`, or use the lambda
+ // overload below from Kotlin.
+
+ @JvmStatic @JvmOverloads
+ fun log(tag: String, message: String, context: Context? = null) =
+ baseLog(Level.DEBUG, tag, message, null, context)
+
+ @JvmStatic @JvmOverloads
+ fun logI(tag: String, message: String, context: Context? = null) =
+ baseLog(Level.INFO, tag, message, null, context)
+
+ @JvmStatic @JvmOverloads
+ fun logW(tag: String, message: String, t: Throwable? = null, context: Context? = null) =
+ baseLog(Level.WARN, tag, message, t, context)
+
+ @JvmStatic @JvmOverloads
+ fun logE(tag: String, message: String, t: Throwable? = null, context: Context? = null) =
+ baseLog(Level.ERROR, tag, message, t, context)
+
+ /**
+ * Kotlin-only sugar for genuinely expensive messages: [message] is never
+ * invoked at all when debug logging is off. Not exposed to Java —
+ * inline functions with function-type parameters don't cross that
+ * boundary cleanly; Java callers should use the isDebugEnabled guard
+ * instead.
+ */
+ inline fun log(tag: String, context: Context? = null, message: () -> String) {
+ if (!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
+ if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_LOGS)
+ != PackageManager.PERMISSION_GRANTED) {
+ logW(TAG, null, context) { "READ_LOGS permission not granted, pause watch may not capture system logs" }
+ }
+
+ stopEventWatch()
+ try {
+ // Wipe the historical buffer so this file only contains lines from
+ // this pause window onward — not hours of unrelated backlog.
+ Runtime.getRuntime().exec(arrayOf("logcat", "-c")).waitFor()
+
+ val safeLabel = label.ifBlank { "manual" }.replace(Regex("[^A-Za-z0-9_-]"), "_")
+ val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val file = File(getLogsDir(context), "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) {
+// Timber.tag(TAG).e("Failed to start event watch: ${e.message}")
+ 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
+
+ // System tags — always included in ALL mode; otherwise filtered like app tags.
+ when (cachedTagFilterMode) {
+ TagFilterMode.ALL -> {
+ // In ALL mode, include ALL system tags that haven't been explicitly
+ // deselected. An empty selection means "show everything" (default),
+ // so only suppress a system tag if it was explicitly unchecked.
+ val excluded = BASELINE_SYSTEM_TAGS.keys - selectedBaseline
+ BASELINE_SYSTEM_TAGS.forEach { (tag, priority) ->
+ if (tag !in excluded || cachedSelectedTags.isEmpty()) {
+ spec.add("$tag:$priority")
+ }
+ }
+ spec.add("*:D")
+ excluded.forEach { spec.add("$it:S") }
+ }
+ TagFilterMode.INCLUDE ->
+ selectedBaseline.forEach { tag ->
+ spec.add("$tag:${BASELINE_SYSTEM_TAGS[tag]}")
+ }
+ TagFilterMode.EXCLUDE ->
+ BASELINE_SYSTEM_TAGS.forEach { (tag, priority) ->
+ if (tag !in selectedBaseline) spec.add("$tag:$priority")
+ }
+ }
+
+ // App tags
+ when (cachedTagFilterMode) {
+ TagFilterMode.ALL -> spec.add("*:D")
+ TagFilterMode.EXCLUDE -> {
+ spec.add("*:D")
+ selectedApp.forEach { spec.add("$it:S") }
+ }
+ TagFilterMode.INCLUDE -> {
+ selectedApp.forEach { spec.add("$it:D") }
+ spec.add("*:S")
+ }
+ }
+
+ return spec
+ }
+
+ // ── 3. Exit/killed reasons | crash trace ──────────────────────────
+ //
+ // No special permission needed (API 30+). Call once, early, on
+ // every app start — it tells you, after the fact, exactly what
+ // 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)
+ if (infos.isEmpty()) {
+ appendLine(ctx, EXIT_REASONS_FILE, "I/$TAG", "No historical exit info available")
+ return
+ }
+ for ((index, info) in infos.withIndex()) {
+ if (cachedExitReasonLogEnabled) {
+ // Separator line with reason number: 0 = newest/last, larger = older
+ appendLine(
+ ctx, EXIT_REASONS_FILE, "I/$TAG",
+ "---- Exit reason #${index} (0=new/last, ${maxExitReasons}=oldest) ----"
+ )
+
+ appendLine(
+ ctx, EXIT_REASONS_FILE, "I/$TAG",
+ "pid=${info.pid} reason=${info.reason}-[${getExitReasonName(info.reason)}] status=${info.status} " +
+ "importance=${info.importance} desc=${info.description} timestamp=${Date(info.timestamp)}",
+ )
+ }
+ if (cachedCrashLogEnabled) {
+ val isErrorReport = when (info.reason) {
+ ApplicationExitInfo.REASON_CRASH,
+ ApplicationExitInfo.REASON_CRASH_NATIVE,
+ ApplicationExitInfo.REASON_ANR -> true
+ else -> false
+ }
+
+ if (isErrorReport) {
+ val type = when (info.reason) {
+ ApplicationExitInfo.REASON_CRASH -> "Java Crash"
+ ApplicationExitInfo.REASON_CRASH_NATIVE -> "Native Crash"
+ ApplicationExitInfo.REASON_ANR -> "ANR"
+ else -> "Critical Error"
+ }
+
+ try {
+ info.traceInputStream?.use { input ->
+ val rawTrace = input.bufferedReader().readText()
+ val summary = summarizeTrace(rawTrace, info.reason)
+ appendLine(
+ ctx, CRASH_FILE, "I/$TAG",
+ "=== Historical $type Detected ===\n" +
+ "PID: ${info.pid} | Timestamp: ${Date(info.timestamp)}\n" +
+ "Description: ${info.description}\n" +
+ "Trace 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}")
+ }
+ }
+ }
+ }
+ } 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")
+ }
+ }
+
+ private fun summarizeTrace(rawTrace: String, reason: Int): String {
+ return when (reason) {
+ ApplicationExitInfo.REASON_ANR -> summarizeAnrTrace(rawTrace)
+ ApplicationExitInfo.REASON_CRASH -> summarizeJavaCrashTrace(rawTrace)
+ ApplicationExitInfo.REASON_CRASH_NATIVE -> summarizeNativeCrashTrace(rawTrace)
+ else -> rawTrace.lines().take(50).joinToString("\n") // unknown: keep first 50 lines
+ }
+ }
+
+ /**
+ * ANR traces contain memory stats, CriticalEventLog boilerplate, and hundreds
+ * of sysTid lines for threads that aren't relevant to the block. Keep only:
+ * - Subject line (the dispatch timeout description)
+ * - Waiting Channels block (which thread is stuck and in what kernel call)
+ * - Any "main" thread or "Binder" thread lines that show who holds the lock
+ */
+ private fun summarizeAnrTrace(raw: String): String {
+ val lines = raw.lines()
+ val out = StringBuilder()
+ var inWaitingChannels = false
+ var inJavaStack = false
+
+ for (line in lines) {
+ when {
+ // Always keep the subject — it describes the actual timeout
+ line.startsWith("Subject:") -> {
+ out.appendLine(line)
+ }
+ // Start of the waiting-channels block
+ line.contains("----- Waiting Channels:") -> {
+ inWaitingChannels = true
+ out.appendLine(line)
+ }
+ // End of waiting-channels block
+ inWaitingChannels && line.startsWith("----- end") -> {
+ out.appendLine(line)
+ inWaitingChannels = false
+ }
+ // Keep all lines inside the waiting-channels block —
+ // sysTid entries show which kernel call each thread is stuck in,
+ // which is the key diagnostic for an ANR.
+ inWaitingChannels -> {
+ out.appendLine(line)
+ }
+ // Java stack section may also appear in ANR traces
+ line.startsWith("----- pid") && line.contains("at") -> {
+ inJavaStack = true
+ out.appendLine(line)
+ }
+ inJavaStack && line.startsWith("----- end") -> {
+ out.appendLine(line)
+ inJavaStack = false
+ }
+ inJavaStack && (line.contains("\"main\"") || line.contains("BLOCKED")
+ || line.contains("at com.winnative") || line.contains("at com.winlator")) -> {
+ out.appendLine(line)
+ }
+ // Skip: RSS stats, VmSwap, CriticalEventLog metadata, libdebuggerd lines
+ }
+ }
+
+ return out.toString().trimEnd().ifEmpty {
+ // Fallback: if the format changed and nothing matched, return first 30 lines
+ lines.take(30).joinToString("\n")
+ }
+ }
+
+ /**
+ * Java crash traces: keep the exception class, message, and the first
+ * meaningful stack frames (app code only — skip framework/runtime frames).
+ */
+ private fun summarizeJavaCrashTrace(raw: String): String {
+ val lines = raw.lines()
+ val out = StringBuilder()
+ var inStack = false
+ var appFrameCount = 0
+ val maxAppFrames = 20
+
+ for (line in lines) {
+ when {
+ // Exception declaration line (e.g. "java.lang.NullPointerException: ...")
+ !inStack && (line.trimStart().startsWith("java.") ||
+ line.trimStart().startsWith("kotlin.") ||
+ line.trimStart().startsWith("android.") && line.contains("Exception")) -> {
+ inStack = true
+ out.appendLine(line)
+ }
+ inStack && line.trimStart().startsWith("at ") -> {
+ val isAppFrame = line.contains("com.winnative") || line.contains("com.winlator")
+ if (isAppFrame && appFrameCount < maxAppFrames) {
+ out.appendLine(line)
+ appFrameCount++
+ } else if (appFrameCount == 0) {
+ // Haven't found any app frames yet — keep first few framework frames
+ // so the trace isn't completely empty if the crash is in a system call
+ if (out.lines().count { it.trimStart().startsWith("at ") } < 5) {
+ out.appendLine(line)
+ }
+ }
+ }
+ inStack && line.trimStart().startsWith("Caused by:") -> {
+ out.appendLine(line)
+ appFrameCount = 0 // reset per cause
+ }
+ inStack && line.isBlank() -> {
+ inStack = false
+ }
+ }
+ }
+
+ return out.toString().trimEnd().ifEmpty { lines.take(30).joinToString("\n") }
+ }
+
+ /**
+ * Native crash traces: keep signal info, fault address, and the backtrace
+ * frames. Skip register dumps (x0-x29 etc.) and memory maps — registers
+ * are unreadable without a debugger and maps are huge.
+ */
+ private fun summarizeNativeCrashTrace(raw: String): String {
+ val lines = raw.lines()
+ val out = StringBuilder()
+ var inBacktrace = false
+ var inMemoryMap = false
+
+ for (line in lines) {
+ when {
+ // Memory map section is always last and always noise
+ line.contains("memory map") || line.contains("memory near") -> {
+ inMemoryMap = true
+ }
+ inMemoryMap -> { /* skip */ }
+
+ // Signal and fault address — the "what crashed" summary
+ line.startsWith("signal ") || line.startsWith("Abort message:") ||
+ line.contains("fault addr") -> {
+ out.appendLine(line)
+ }
+ // Backtrace section header
+ line.trimStart().startsWith("backtrace:") -> {
+ inBacktrace = true
+ out.appendLine(line)
+ }
+ // Backtrace frames
+ inBacktrace && line.trimStart().startsWith("#") -> {
+ out.appendLine(line)
+ }
+ inBacktrace && line.isBlank() -> {
+ inBacktrace = false
+ }
+ // Skip: register dumps (lines like " x0 0000..." or " lr ...")
+ }
+ }
+
+ return out.toString().trimEnd().ifEmpty { lines.take(30).joinToString("\n") }
+ }
}
diff --git a/app/src/main/runtime/system/ProcessHelper.java b/app/src/main/runtime/system/ProcessHelper.java
index 15ef604bc..3612d3adc 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 = "WineOomProtect";
+
public static native int reapDeadChildrenNow();
public static native void startNativeReaperWindow(int durationMs);
@@ -228,7 +253,7 @@ public static void protectAllWineProcesses() {
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 +262,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 +716,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..4a9c0a26d 100644
--- a/app/src/main/runtime/system/SessionKeepAliveService.java
+++ b/app/src/main/runtime/system/SessionKeepAliveService.java
@@ -1,32 +1,43 @@
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 +50,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,7 +63,10 @@ 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<>();
@@ -60,38 +77,127 @@ public class SessionKeepAliveService extends Service {
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 +216,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 isAppVisible() {
+ 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 +260,476 @@ 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() ||
+ (isAppVisible() && 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) {
+ 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);
+ 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();
+ // 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;
}
- // Ensure wake lock, wifi lock and OOM adj are correct based on current state
+ // State is already current by the time this runs — every caller mutates
+ // it before this Intent is ever sent. This just reconciles the actual
+ // foreground/running status against that state.
if (hasReason()) {
- 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();
+ ensureForeground();
+ serviceRunning.set(true);
} else {
- if (wakeLock != null && wakeLock.isHeld()) wakeLock.release();
- if (wifiLock != null && wifiLock.isHeld()) wifiLock.release();
- ProcessHelper.setOomScoreAdj(android.os.Process.myPid(), 0);
- }
-
- // Always promote to foreground first so Android does not consider
- // the start a violation (and so the notification reflects current
- // reasons), even if the command immediately tells us to stop.
- ensureForeground();
- serviceRunning.set(true);
-
- if (!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.
+ boolean showExit = isAppVisible() && 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() {
+ 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;
+ }
+
+ 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) {
+// Timber.tag(TAG).e(e, "OOM protection sweep failed");
+ LogManager.logE(TAG, "OOM protection sweep failed", e, this);
+ }
+ }
+
+ @NonNull
+ private static String getNotificationContent() {
+ // 1. HIGHEST PRIORITY: The game/container
+ if (sessionActive.get()) {
+ return isContainerPaused ? "Container session is paused" : "There is a container session running";
+ }
+
+ // 2. MEDIUM PRIORITY: Downloads
synchronized (activeDownloads) {
- activeDownloads.clear();
+ if (!activeDownloads.isEmpty()) return "Downloading and installing components in the background";
+ }
+
+ // 3. MEDIUM PRIORITY: Steam friends (if enabled)
+ if (PrefManager.INSTANCE.getChatStayRunningOnExit() && isAppVisible())
+ return "Steam chat running in background";
+
+ // 4. LOW PRIORITY: Active store services
+ if (!activeComponents.isEmpty()) {
+ // 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) + " and " + names.get(1);
+ break;
+ default:
+ // Future-proof: "A, B, and C"
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < size; i++) {
+ sb.append(names.get(i));
+ if (i < size - 2) {
+ sb.append(", ");
+ } else if (i == size - 2) {
+ sb.append(", and ");
+ }
+ }
+ joinedNames = sb.toString();
+ }
+
+ /*return names.size() == 1
+ ? names.get(0) + " service is active"
+ : String.join(" and ", names) + " services are active";*/
+
+ String suffix = (size == 1) ? " service is active" : " services are 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();
+ if (instance != null) {
+ instance.stopForegroundCompat();
+ instance.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);
+ // 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" }