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" }