diff --git a/app/build.gradle b/app/build.gradle index 62637d031..7e9312a64 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,6 +8,8 @@ plugins { alias(libs.plugins.spotless) } +apply from: rootProject.file('gradle/collectLogTags.gradle') + /* abstract class InstallVulkanValidationLayerTask extends DefaultTask { @Input @@ -59,6 +61,10 @@ task checkSubmodules { preBuild.dependsOn(checkSubmodules) +tasks.named('preBuild') { + dependsOn 'collectLogTags' +} + /* def installVulkanValidationLayer = tasks.register("installVulkanValidationLayer", InstallVulkanValidationLayerTask) { layerVersion.set("1.4.341.0") @@ -205,11 +211,13 @@ android { sourceSets { main { java.srcDirs = [ +// 'src/main/java', // Needed for Android Studio 'src/main/app', 'src/main/feature', 'src/main/sharedmemory', 'src/main/runtime', - 'src/main/shared' + 'src/main/shared', + 'build/generated/source/logtags' // Generated by collectLogTags.gradle ] } } @@ -229,7 +237,7 @@ android { } dependencies { - // debugImplementation "com.squareup.leakcanary:leakcanary-android:2.14" +// debugImplementation "com.squareup.leakcanary:leakcanary-android:2.14" implementation libs.okhttp implementation libs.okhttpDnsOverHttps @@ -277,6 +285,7 @@ dependencies { implementation files('libs/MidiSynth/MidiSynth.jar') implementation libs.recyclerview implementation libs.coreKtx + implementation(libs.lifecycleProcess) // New = compilation errors / gradle plugin update requirement. implementation 'com.android.ndk.thirdparty:openssl:1.1.1q-beta-1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dcb6bc9e1..4b36f816d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,6 +36,7 @@ + diff --git a/app/src/main/app/PluviaApp.kt b/app/src/main/app/PluviaApp.kt index b52d883b9..418f09493 100644 --- a/app/src/main/app/PluviaApp.kt +++ b/app/src/main/app/PluviaApp.kt @@ -11,6 +11,7 @@ import com.winlator.cmod.feature.stores.steam.events.EventDispatcher import com.winlator.cmod.feature.stores.steam.service.SteamService import com.winlator.cmod.feature.stores.steam.utils.PrefManager import com.winlator.cmod.runtime.display.XServerDisplayActivity +import com.winlator.cmod.runtime.system.LogManager import com.winlator.cmod.shared.android.RefreshRateUtils import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope @@ -18,6 +19,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber import java.io.File @HiltAndroidApp @@ -40,6 +42,16 @@ class PluviaApp : Application() { .init(this) scheduleColdStartWarmups() + // Initialize Timber in debug builds for app wide logging. + // If 'BuildConfig' give error, ignore it. + // The file needed will be autogenerated when building the apk. + if (com.winlator.cmod.BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()); + } + + // Initialize the LogManager context in case a fallback is needed. + LogManager.init(this.applicationContext) + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Log.e("PluviaApp", "CRASH in thread ${thread.name}", throwable) } diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index ab661f60d..ce4b71bd2 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -914,6 +914,7 @@ class UnifiedActivity : override fun onCreate(savedInstanceState: Bundle?) { instance = this super.onCreate(savedInstanceState) + if (!SetupWizardActivity.isSetupComplete(this) || !ImageFs.find(this).isUpToDate) { startActivity( Intent(this, SetupWizardActivity::class.java) diff --git a/app/src/main/cpp/winlator/xconnector_epoll.c b/app/src/main/cpp/winlator/xconnector_epoll.c index 1625a9235..1dd8b1f92 100644 --- a/app/src/main/cpp/winlator/xconnector_epoll.c +++ b/app/src/main/cpp/winlator/xconnector_epoll.c @@ -9,6 +9,7 @@ #include #include #include +#include #define printf(...) \ __android_log_print(ANDROID_LOG_DEBUG, "System.out", __VA_ARGS__); @@ -74,6 +75,10 @@ Java_com_winlator_cmod_runtime_display_connector_XConnectorEpoll_doEpollIndefini if (events[i].data.fd == serverFd) { int clientFd = accept(serverFd, NULL, NULL); if (clientFd >= 0) { + // Set a write timeout to avoid hanging the XServer if a client is stopped (SIGSTOP) + struct timeval tv = {.tv_sec = 2, .tv_usec = 0}; + setsockopt(clientFd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + if (addClientToEpoll) { struct epoll_event event; event.data.fd = clientFd; diff --git a/app/src/main/feature/settings/debug/DebugFragment.kt b/app/src/main/feature/settings/debug/DebugFragment.kt index 9d94a673b..928b42337 100644 --- a/app/src/main/feature/settings/debug/DebugFragment.kt +++ b/app/src/main/feature/settings/debug/DebugFragment.kt @@ -1,5 +1,6 @@ // Settings > Debug fragment — hosts DebugScreen via ComposeView. package com.winlator.cmod.feature.settings +import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.os.Bundle @@ -8,11 +9,11 @@ import android.os.Looper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView @@ -25,6 +26,8 @@ import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import com.winlator.cmod.R import com.winlator.cmod.app.config.SettingsConfig +import com.winlator.cmod.runtime.system.GeneratedLogTags +import com.winlator.cmod.runtime.system.LogManager import com.winlator.cmod.app.shell.UnifiedActivity import com.winlator.cmod.shared.io.AssetPaths import com.winlator.cmod.shared.io.FileUtils @@ -70,6 +73,31 @@ class DebugFragment : Fragment() { state = debugState, wineChannelOptions = wineChannelOptions, wineClassOptions = SettingsConfig.WINE_DEBUG_CLASSES, + onLoggingEnabledChanged = { checked -> + preferences.edit { + putBoolean("enable_application_logging", checked) + if (!checked) { + putBoolean("enable_app_debug", false) + putBoolean("enable_input_logs", false) + putBoolean("enable_download_logs", false) + putBoolean("enable_exit_reason_log", false) + putBoolean("enable_crash_log", false) + putBoolean("enable_filtered_logs", false) + putBoolean("enable_event_watch_log", false) + } + } + if (!checked) { + com.winlator.cmod.feature.stores.steam.utils.PrefManager.enableSteamLogs = false + com.winlator.cmod.runtime.system.ApplicationLogGate + .setEnabled(false) + } + + refresh() + }, + onLogcatGroupExpandedChanged = { expanded -> + preferences.edit { putBoolean("debug_group_logcat_expanded", expanded) } + refresh() + }, onAppDebugChanged = { checked -> preferences.edit { putBoolean("enable_app_debug", checked) } com.winlator.cmod.runtime.system.ApplicationLogGate @@ -85,6 +113,55 @@ class DebugFragment : Fragment() { } refresh() }, + onFilteredLogsChanged = { checked -> + preferences.edit { putBoolean("enable_filtered_logs", checked) } + if (!checked) { + com.winlator.cmod.runtime.system.LogManager + .clearManualTextFilter() + if (!debugState.eventWatchLog) { + 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(debugState.customTags) { LogManager.getAllKnownTags() }, + onExitGroupExpandedChanged = { expanded -> + preferences.edit { putBoolean("debug_group_exit_expanded", expanded) } + refresh() + }, + onFilterGroupExpandedChanged = { expanded -> + preferences.edit { putBoolean("debug_group_filter_expanded", expanded) } + refresh() + }, onWineDebugChanged = { checked -> preferences.edit { putBoolean("enable_wine_debug", checked) } com.winlator.cmod.runtime.system.LogManager @@ -127,18 +204,21 @@ class DebugFragment : Fragment() { ) { timber.log.Timber.plant(timber.log.Timber.DebugTree()) } + if (checked) ensureAppLoggingOn() com.winlator.cmod.runtime.system.LogManager .updateLoggingState(ctx) refresh() }, onInputLogsChanged = { checked -> preferences.edit { putBoolean("enable_input_logs", checked) } + if (checked) ensureAppLoggingOn() com.winlator.cmod.runtime.system.LogManager .updateLoggingState(ctx) refresh() }, onDownloadLogsChanged = { checked -> preferences.edit { putBoolean("enable_download_logs", checked) } + if (checked) ensureAppLoggingOn() com.winlator.cmod.runtime.system.LogManager .updateLoggingState(ctx) refresh() @@ -205,7 +285,20 @@ class DebugFragment : Fragment() { ?.filter { it.isNotBlank() } ?: emptyList() debugState = DebugState( + loggingEnabled = preferences.getBoolean("enable_application_logging", false), appDebug = preferences.getBoolean("enable_app_debug", false), + filteredLogs = preferences.getBoolean("enable_filtered_logs", false), + exitReasonLog = preferences.getBoolean("enable_exit_reason_log", false), + crashLog = preferences.getBoolean("enable_crash_log", false), + eventWatchLog = preferences.getBoolean("enable_event_watch_log", false), + tagFilterMode = LogManager.getTagFilterMode(), + selectedTags = LogManager.getSelectedTags().toList(), + customTags = LogManager.getCachedCustomTags(), + + logcatGroupExpanded = preferences.getBoolean("debug_group_logcat_expanded", false), + exitGroupExpanded = preferences.getBoolean("debug_group_exit_expanded", false), + filterGroupExpanded = preferences.getBoolean("debug_group_filter_expanded", false), + wineDebug = preferences.getBoolean("enable_wine_debug", false), wineChannels = channels, wineClasses = classes, @@ -455,6 +548,17 @@ class DebugFragment : Fragment() { refresh() } + // Steam/input/download logs are only flags on log statements that land in the application + // log capture, so they produce nothing unless application logging is running. + private fun ensureAppLoggingOn() { + if (preferences.getBoolean("enable_app_debug", false)) return + preferences.edit { putBoolean("enable_app_debug", true) } + com.winlator.cmod.runtime.system.ApplicationLogGate + .setEnabled(true) + com.winlator.cmod.runtime.system.LogManager + .startAppLogging(requireContext(), reset = false) + } + companion object { private const val KEY_DOWNLOADED_LOGS = "downloaded_log_keys" diff --git a/app/src/main/feature/settings/debug/DebugScreen.kt b/app/src/main/feature/settings/debug/DebugScreen.kt index 28062eba8..52a51351c 100644 --- a/app/src/main/feature/settings/debug/DebugScreen.kt +++ b/app/src/main/feature/settings/debug/DebugScreen.kt @@ -1,13 +1,17 @@ package com.winlator.cmod.feature.settings +import android.os.Build import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize 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 +135,24 @@ 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.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction + +import com.winlator.cmod.runtime.system.LogManager // Palette (mirrors StoresScreen) private val BgDark = Color(0xFF11111C) @@ -144,10 +166,24 @@ private val Warning = Color(0xFFFF4444) private val Success = Color(0xFF7CC142) private val TextPrimary = Color(0xFFF0F4FF) private val TextSecondary = Color(0xFF7A8FA8) +private val SystemTagColor = Color(0xFFFFCC00) // State data class DebugState( + val loggingEnabled: Boolean = false, val appDebug: Boolean = false, + val filteredLogs: 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 logcatGroupExpanded: Boolean = false, + val exitGroupExpanded: Boolean = false, + val filterGroupExpanded: Boolean = false, + val wineDebug: Boolean = false, val wineChannels: List = emptyList(), val wineClasses: List = emptyList(), @@ -175,7 +211,21 @@ fun DebugScreen( state: DebugState, wineChannelOptions: List, wineClassOptions: List, + allLogTagOptions: List, // LogManager.getAllKnownTags() + onLoggingEnabledChanged: (Boolean) -> Unit, onAppDebugChanged: (Boolean) -> Unit, + onFilteredLogsChanged: (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 + onLogcatGroupExpandedChanged: (Boolean) -> Unit, + onExitGroupExpandedChanged: (Boolean) -> Unit, + onFilterGroupExpandedChanged: (Boolean) -> Unit, onWineDebugChanged: (Boolean) -> Unit, onWineChannelsChanged: (List) -> Unit, onWineClassesChanged: (List) -> Unit, @@ -199,6 +249,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 +269,23 @@ fun DebugScreen( ) } + if (showTagFilterDialog) { + LogTagFilterDialog( + options = allLogTagOptions, + initiallySelected = state.selectedTags, + initialMode = state.tagFilterMode, + customTags = state.customTags, + onDismiss = { showTagFilterDialog = false }, + onConfirm = { mode, selected -> + onTagFilterModeChanged(mode) + onSelectedTagsChanged(selected) + showTagFilterDialog = false + }, + onAddCustomTag = onAddCustomTag, + onRemoveCustomTag = onRemoveCustomTag, + ) + } + if (showLogsBrowser) { val initialFiles = remember { onListLogFiles() } LogsBrowserDialog( @@ -236,6 +304,11 @@ fun DebugScreen( val contentNav = rememberSettingsContentNav(bridge) + // Enable stable cursor to prevent focus jumping during layout shifts (like collapsing groups) + androidx.compose.runtime.SideEffect { + contentNav.stableCursor = true + } + CompositionLocalProvider(LocalPaneNav provides contentNav) { Column( modifier = @@ -254,14 +327,156 @@ fun DebugScreen( SectionLabel(stringResource(R.string.common_ui_application)) SettingsToggleCard( - title = stringResource(R.string.common_ui_application), - subtitle = stringResource(R.string.settings_debug_log_to_file_desc), + title = stringResource(R.string.settings_debug_application_logging_title), + subtitle = stringResource(R.string.settings_debug_application_logging_subtitle), icon = Icons.Outlined.BugReport, accentColor = Warning, - checked = state.appDebug, - onCheckedChange = onAppDebugChanged, + checked = state.loggingEnabled, + onCheckedChange = onLoggingEnabledChanged, ) + AnimatedVisibility( + visible = state.loggingEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + // Logcat group + CollapsibleGroup( + title = stringResource(R.string.settings_debug_logcat_title), + accentColor = TextSecondary, + expanded = state.logcatGroupExpanded, + onExpandedChange = onLogcatGroupExpandedChanged, + ) { + SettingsToggleCard( + title = stringResource(R.string.settings_debug_application_logging_title), + subtitle = stringResource(R.string.settings_debug_log_to_file_desc), + icon = Icons.Outlined.BugReport, + accentColor = Warning, + checked = state.appDebug, + onCheckedChange = onAppDebugChanged, + ) + + SettingsToggleCard( + title = stringResource(R.string.settings_debug_steam_logs_title), + subtitle = stringResource(R.string.settings_debug_steam_logs_subtitle), + icon = Icons.Outlined.SportsEsports, + checked = state.steamLogs, + onCheckedChange = onSteamLogsChanged, + ) + + SettingsToggleCard( + title = stringResource(R.string.settings_debug_input_logs), + subtitle = stringResource(R.string.settings_debug_input_logs_description), + icon = Icons.Outlined.Gamepad, + checked = state.inputLogs, + onCheckedChange = onInputLogsChanged, + ) + + SettingsToggleCard( + title = stringResource(R.string.settings_debug_download_logs), + subtitle = stringResource(R.string.settings_debug_download_logs_description), + icon = Icons.Outlined.CloudDownload, + checked = state.downloadLogs, + onCheckedChange = onDownloadLogsChanged, + ) + } + } + + AnimatedVisibility( + visible = state.loggingEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + // Exit group + CollapsibleGroup( + title = stringResource(R.string.common_ui_exit), + accentColor = TextSecondary, + expanded = state.exitGroupExpanded, + onExpandedChange = onExitGroupExpandedChanged, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // This log only works on API 30+ (Android 11+) + SettingsToggleCard( + title = stringResource(R.string.settings_debug_exit_reason_log_title), + subtitle = stringResource(R.string.settings_debug_exit_reason_log_subtitle), + icon = Icons.Outlined.BugReport, + accentColor = SystemTagColor, + checked = state.exitReasonLog, + onCheckedChange = onExitReasonLogChanged, + ) + } + + SettingsToggleCard( + title = stringResource(R.string.settings_debug_crash_log_title), + subtitle = stringResource(R.string.settings_debug_crash_log_subtitle), + icon = Icons.Outlined.BugReport, + accentColor = SystemTagColor, + checked = state.crashLog, + onCheckedChange = onCrashLogChanged, + ) + } + } + + AnimatedVisibility( + visible = state.loggingEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + // Filter group + CollapsibleGroup( + title = stringResource(R.string.session_gyroscope_filtering), + accentColor = TextSecondary, + expanded = state.filterGroupExpanded, + onExpandedChange = onFilterGroupExpandedChanged, + ) { + SettingsToggleCard( + title = stringResource(R.string.settings_filtered_logs_to_file_title), + subtitle = stringResource(R.string.settings_filtered_logs_to_file_desc), + icon = Icons.Outlined.BugReport, + accentColor = Warning, + checked = state.filteredLogs, + onCheckedChange = onFilteredLogsChanged, + ) + + SettingsToggleCard( + title = stringResource(R.string.settings_debug_event_watch_log_title), + subtitle = stringResource(R.string.settings_debug_event_watch_log_subtitle), + icon = Icons.Outlined.BugReport, + accentColor = SystemTagColor, + checked = state.eventWatchLog, + onCheckedChange = onEventWatchLogChanged, + ) + + AnimatedVisibility( + visible = state.filteredLogs || state.eventWatchLog, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LogTagFilterCard( + mode = state.tagFilterMode, + selectedTags = state.selectedTags, + enabled = state.filteredLogs || state.eventWatchLog, + onEdit = { showTagFilterDialog = true }, + onModeChanged = onTagFilterModeChanged, + onRemoveSelectedTag = { tag -> + onSelectedTagsChanged(state.selectedTags - tag) + }, + ) + } + + AnimatedVisibility( + visible = state.filteredLogs || state.eventWatchLog, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + ManualTextFilterField( + enabled = state.filteredLogs || state.eventWatchLog, + resetKey = state.filteredLogs to state.eventWatchLog, + onTextChanged = onManualTextFilterChanged, + ) + } + } + } + SectionLabel(stringResource(R.string.settings_debug_section_emulation), modifier = Modifier.padding(top = 8.dp)) SettingsToggleCard( @@ -316,30 +531,6 @@ fun DebugScreen( ) */ - SettingsToggleCard( - title = stringResource(R.string.settings_debug_steam_logs_title), - subtitle = stringResource(R.string.settings_debug_steam_logs_subtitle), - icon = Icons.Outlined.SportsEsports, - checked = state.steamLogs, - onCheckedChange = onSteamLogsChanged, - ) - - SettingsToggleCard( - title = stringResource(R.string.settings_debug_input_logs), - subtitle = stringResource(R.string.settings_debug_input_logs_description), - icon = Icons.Outlined.Gamepad, - checked = state.inputLogs, - onCheckedChange = onInputLogsChanged, - ) - - SettingsToggleCard( - title = stringResource(R.string.settings_debug_download_logs), - subtitle = stringResource(R.string.settings_debug_download_logs_description), - icon = Icons.Outlined.CloudDownload, - checked = state.downloadLogs, - onCheckedChange = onDownloadLogsChanged, - ) - SettingsToggleCard( title = stringResource(R.string.settings_hud_record_to_file_title), subtitle = stringResource(R.string.settings_hud_record_to_file_summary), @@ -349,7 +540,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( @@ -381,6 +575,64 @@ private fun SectionLabel( ) } +@Composable +private fun ManualTextFilterField( + enabled: Boolean, + resetKey: Any, + onTextChanged: (String) -> Unit, +) { + var text by remember { mutableStateOf(LogManager.getManualTextFilter()) } + LaunchedEffect(resetKey) { text = LogManager.getManualTextFilter() } + var focused by remember { mutableStateOf(false) } + val focusRequester = remember { androidx.compose.ui.focus.FocusRequester() } + val focusManager = LocalFocusManager.current + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .height(38.dp) + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = if (focused) Accent else CardBorder, + shape = RoundedCornerShape(8.dp), + ).paneNavItem( + cornerRadius = 8.dp, + onActivate = { if (enabled) runCatching { focusRequester.requestFocus() } }, + highlightColor = NavHighlight, + tapToSelect = true, + ).padding(horizontal = 12.dp), + contentAlignment = Alignment.CenterStart, + ) { + if (text.isEmpty()) { + Text( + text = stringResource(R.string.settings_debug_manual_text_filter_hint), + fontSize = 12.sp, + color = TextSecondary, + ) + } + androidx.compose.foundation.text.BasicTextField( + value = text, + onValueChange = { + text = it + onTextChanged(it) + }, + enabled = enabled, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle(color = TextPrimary, fontSize = 13.sp), + cursorBrush = SolidColor(Accent), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { focused = it.isFocused }, + ) + } +} + // Settings toggle card @Composable private fun SettingsToggleCard( @@ -408,7 +660,8 @@ private fun SettingsToggleCard( onActivate = { onCheckedChange(!checked) }, highlightColor = NavHighlight, tapToSelect = true, - ).padding(horizontal = 14.dp, vertical = 11.dp), + ) + .padding(horizontal = 14.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( @@ -435,7 +688,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, @@ -563,7 +818,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 +927,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, @@ -746,7 +1126,8 @@ private fun SmallActionButton( }, onTap = { onClick() }, ) - }.padding(horizontal = 11.dp, vertical = 6.dp), + } + .padding(horizontal = 11.dp, vertical = 6.dp), contentAlignment = Alignment.Center, ) { Text( @@ -758,16 +1139,21 @@ private fun SmallActionButton( } } -// Wine debug channel selector dialog +// Generic MultiSelectDialog @Composable -private fun WineChannelsDialog( +private fun MultiSelectDialog( + title: String, options: List, initiallySelected: List, onDismiss: () -> Unit, onConfirm: (List) -> Unit, + headerContent: (@Composable () -> Unit)? = null, + extraContent: (@Composable (selected: Set, onToggle: (String) -> Unit) -> Unit)? = null, + systemTags: Set = emptySet(), + maxWidth: Dp = 460.dp, ) { val selected = - remember(initiallySelected) { + remember { mutableStateOf(initiallySelected.toSet()) } val contentRegistry = remember { PaneNavRegistry().apply { stableCursor = true } } @@ -778,6 +1164,10 @@ private fun WineChannelsDialog( val density = LocalDensity.current var viewportTop by remember { mutableStateOf(0f) } var viewportHeight by remember { mutableIntStateOf(0) } + // Capture latest options and callbacks to avoid stale data in remembered handlers + val currentOptions by rememberUpdatedState(options) + val currentOnConfirm by rememberUpdatedState(onConfirm) + val currentOnDismiss by rememberUpdatedState(onDismiss) LaunchedEffect(contentRegistry.activeRow, contentRegistry.activeCol, viewportHeight, footerZone) { if (footerZone || !contentRegistry.controllerActive || contentRegistry.manualSelection) return@LaunchedEffect @@ -818,10 +1208,10 @@ private fun WineChannelsDialog( val handlers = remember { paneNavHandlers( - onDismiss = onDismiss, + onDismiss = { currentOnDismiss() }, onStart = { - val ordered = options.filter { it in selected.value } - onConfirm(ordered) + val ordered = currentOptions.filter { it in selected.value } + currentOnConfirm(ordered) }, registry = { if (footerZone) footerRegistry else contentRegistry }, ) @@ -849,7 +1239,7 @@ private fun WineChannelsDialog( Box( modifier = Modifier - .widthIn(max = 460.dp) + .widthIn(max = maxWidth) .fillMaxWidth() .heightIn(max = availableHeight) .clip(RoundedCornerShape(18.dp)) @@ -859,21 +1249,21 @@ private fun WineChannelsDialog( ) { Column(modifier = Modifier.fillMaxHeight()) { Text( - text = stringResource(R.string.settings_debug_wine_debug_channel), + text = title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, ) Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(R.string.settings_debug_channel_toggle_hint), - color = TextSecondary, - fontSize = 12.sp, - ) + + headerContent?.invoke() + + // Optional hint area can be provided by caller via extraContent or you can keep a default hint Spacer(Modifier.height(12.dp)) ChannelGrid( options = options, + systemTags = systemTags, selected = selected.value, gridState = gridState, onViewport = { top, h -> @@ -890,6 +1280,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 +1317,258 @@ private fun WineChannelsDialog( } } + +// Wine debug channel selector dialog +@Composable +private fun WineChannelsDialog( + options: List, + initiallySelected: List, + onDismiss: () -> Unit, + onConfirm: (List) -> Unit, +) { + MultiSelectDialog( + title = stringResource(R.string.settings_debug_wine_debug_channel), + options = options, + initiallySelected = initiallySelected, + onDismiss = onDismiss, + onConfirm = onConfirm, + extraContent = null // no extra UI needed for wine channels + ) +} + +@Composable +private fun LogTagFilterDialog( + options: List, + initiallySelected: List, + initialMode: LogManager.TagFilterMode, + customTags: List, + onDismiss: () -> Unit, + onConfirm: (LogManager.TagFilterMode, List) -> Unit, + onAddCustomTag: (String) -> Unit, + onRemoveCustomTag: (String) -> Unit, +) { + var mode by remember { mutableStateOf(initialMode) } + var newTagText by remember { mutableStateOf("") } + // Custom tags are just as selectable as the build-discovered ones — merge + // them into the grid instead of only listing them in the management row below. + val allOptions = remember(options, customTags) { (options + customTags).distinct().sorted() } + + MultiSelectDialog( + title = stringResource(R.string.settings_debug_log_tag_filter_channel), + options = allOptions, + systemTags = remember { LogManager.getSystemTags() }, + initiallySelected = initiallySelected, + onDismiss = onDismiss, + onConfirm = { selected -> onConfirm(mode, selected) }, + maxWidth = 680.dp, + headerContent = { + Column(modifier = Modifier.padding(top = 16.dp)) { + // Top Row: Mode Switch and Add Field side-by-side + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(20.dp) + ) { + // Mode Section + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_debug_tag_filter_mode).uppercase(), + color = TextSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp + ) + Spacer(Modifier.height(8.dp)) + SegmentedControl( + options = listOf( + stringResource(R.string.settings_debug_tag_filter_all), + stringResource(R.string.settings_debug_tag_filter_include), + stringResource(R.string.settings_debug_tag_filter_exclude), + ), + selectedIndex = when (mode) { + LogManager.TagFilterMode.ALL -> 0 + LogManager.TagFilterMode.INCLUDE -> 1 + LogManager.TagFilterMode.EXCLUDE -> 2 + }, + onSelectedIndex = { idx -> + mode = when (idx) { + 0 -> LogManager.TagFilterMode.ALL + 1 -> LogManager.TagFilterMode.INCLUDE + else -> LogManager.TagFilterMode.EXCLUDE + } + } + ) + } + + // Add Custom Tag Section + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_debug_add_custom_tag_hint).uppercase(), + color = TextSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp + ) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + // Simple state to track focus for the border color + var isTextFieldFocused by remember { mutableStateOf(false) } + + // Use BasicTextField for full control over vertical padding + androidx.compose.foundation.text.BasicTextField( + value = newTagText, + onValueChange = { newTagText = it }, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle( + color = TextPrimary, + fontSize = 13.sp + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(Accent), + modifier = Modifier + .weight(1f) + .height(38.dp) + .onFocusChanged { isTextFieldFocused = it.isFocused }, + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .background(Color.Transparent, RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = if (isTextFieldFocused) Accent else CardBorder, + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.CenterStart + ) { + if (newTagText.isEmpty()) { + Text( + text = stringResource(R.string.settings_debug_add_custom_tag_hint), + fontSize = 12.sp, + color = TextSecondary + ) + } + innerTextField() + } + } + ) + Spacer(Modifier.width(8.dp)) + SmallActionButton( + label = stringResource(R.string.common_ui_add), + textColor = Accent, + onClick = { + val tag = newTagText.trim() + if (tag.isNotEmpty()) { + onAddCustomTag(tag) + newTagText = "" + } + } + ) + } + } + } + + // Custom Tags row + if (customTags.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + HorizontalDivider(color = Color(0xFF2A2A3A), thickness = 0.5.dp) + Spacer(Modifier.height(6.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("CUSTOM:", color = TextSecondary, fontSize = 10.sp, fontWeight = FontWeight.Bold) + customTags.forEach { tag -> + RemovableTagChip(tag = tag, onRemove = { onRemoveCustomTag(tag) }) + } + } + Spacer(Modifier.height(3.dp)) + HorizontalDivider(color = Color(0xFF2A2A3A), thickness = 0.5.dp) + } + } + }, + ) +} + +@Composable +private fun SegmentedControl( + options: List, + selectedIndex: Int, + onSelectedIndex: (Int) -> Unit, +) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(SurfaceDark) + .border(1.dp, CardBorder, RoundedCornerShape(10.dp)) + .padding(2.dp), + ) { + options.forEachIndexed { index, label -> + val isSelected = index == selectedIndex + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (isSelected) Accent else Color.Transparent) + // Add paneNavItem so the controller can focus this option + .paneNavItem( + cornerRadius = 8.dp, + onActivate = { onSelectedIndex(index) }, + highlightColor = NavHighlight, + tapToSelect = true + ) + .clickable { onSelectedIndex(index) } + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = if (isSelected) Color.White else TextSecondary, + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } + } +} + +@Composable +private fun RemovableTagChip(tag: String, onRemove: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(SurfaceDark) + .border(1.dp, CardBorder, RoundedCornerShape(20.dp)) + .padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + ) { + Text(text = tag, color = TextPrimary, fontSize = 12.sp) + Spacer(Modifier.width(4.dp)) + Box( + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(10.dp)) + // Add paneNavItem to the 'X' button container + .paneNavItem( + cornerRadius = 10.dp, + onActivate = { onRemove() }, + highlightColor = NavHighlight, + tapToSelect = true + ) + .clickable { onRemove() }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(14.dp), + ) + } + } +} + @Composable private fun ColumnScope.ChannelGrid( options: List, @@ -924,6 +1576,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 +1601,14 @@ private fun ColumnScope.ChannelGrid( verticalArrangement = Arrangement.spacedBy(8.dp), ) { itemsIndexed(options) { index, channel -> + val isSystem = channel in systemTags + val chipAccent = if (isSystem) SystemTagColor else Accent SelectableChannelChip( label = channel, isSelected = channel in selected, isEntry = index == 0, onToggle = { onToggle(channel) }, + tint = chipAccent, ) } } @@ -964,10 +1620,22 @@ private fun SelectableChannelChip( isSelected: Boolean, isEntry: Boolean, onToggle: () -> Unit, + tint: Color = Accent, ) { - val bg = if (isSelected) Accent.copy(alpha = 0.18f) else IconBoxBg - val borderColor = if (isSelected) Accent.copy(alpha = 0.55f) else CardBorder - val textColor = if (isSelected) Accent else TextPrimary + val isSystemTag = tint == SystemTagColor + + val bg = if (isSelected) tint.copy(alpha = 0.18f) else IconBoxBg + // If it's a system tag, show a subtle tinted border and text even when unselected + val borderColor = when { + isSelected -> tint.copy(alpha = 0.55f) + isSystemTag -> tint.copy(alpha = 0.35f) // Subtle yellow border for system tags + else -> CardBorder + } + val textColor = when { + isSelected -> tint + isSystemTag -> tint.copy(alpha = 0.85f) // Dimmed yellow text for system tags + else -> TextPrimary + } Box( modifier = Modifier @@ -1015,7 +1683,11 @@ private fun RowScope.LogActionButton( .clip(RoundedCornerShape(12.dp)) .background(CardDark) .border(1.dp, accentColor.copy(alpha = 0.22f), RoundedCornerShape(12.dp)) - .paneNavItem(cornerRadius = 12.dp, onActivate = { onClick() }, highlightColor = NavHighlight) + .paneNavItem( + cornerRadius = 12.dp, + onActivate = { onClick() }, + highlightColor = NavHighlight + ) .pointerInput(onClick) { detectTapGestures( onPress = { @@ -1025,7 +1697,8 @@ private fun RowScope.LogActionButton( }, onTap = { onClick() }, ) - }.padding(horizontal = 10.dp, vertical = 7.dp), + } + .padding(horizontal = 10.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { @@ -1232,7 +1905,8 @@ private fun LogsHeaderShareAll(onClick: () -> Unit) { }, onTap = { onClick() }, ) - }.padding(horizontal = 10.dp, vertical = 6.dp), + } + .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -1277,7 +1951,8 @@ private fun LogsHeaderDownloadAll(onClick: () -> Unit) { }, onTap = { onClick() }, ) - }.padding(horizontal = 10.dp, vertical = 6.dp), + } + .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -1322,7 +1997,8 @@ private fun LogsHeaderDeleteAll(onClick: () -> Unit) { }, onTap = { onClick() }, ) - }.padding(horizontal = 10.dp, vertical = 6.dp), + } + .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -1473,7 +2149,8 @@ private fun LogFileRow( .paneNavItem(cornerRadius = 8.dp, onActivate = { onOpen() }) .pointerInput(entry.absolutePath) { detectTapGestures(onTap = { onOpen() }) - }.padding(horizontal = 4.dp, vertical = 2.dp), + } + .padding(horizontal = 4.dp, vertical = 2.dp), ) { Text( text = entry.name, @@ -1718,7 +2395,10 @@ private fun LogContentBody( .clip(RoundedCornerShape(10.dp)) .background(SurfaceDark) .border(1.dp, CardBorder, RoundedCornerShape(10.dp)) - .verticalScrollbar(logScrollState, TextSecondary.copy(alpha = 0.6f)) { scrollbarAlpha } + .verticalScrollbar( + logScrollState, + TextSecondary.copy(alpha = 0.6f) + ) { scrollbarAlpha } .padding(10.dp) .verticalScroll(logScrollState), ) { @@ -1767,3 +2447,77 @@ private fun Modifier.verticalScrollbar( alpha = thumbAlpha, ) } + +@Composable +private fun CollapsibleGroup( + title: String, + subtitle: String? = null, + accentColor: Color = Accent, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + content: @Composable () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, CardBorder, RoundedCornerShape(12.dp)), + colors = CardDefaults.cardColors(containerColor = CardDark), + shape = RoundedCornerShape(12.dp) + ) { + Column(modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .paneNavItem( + cornerRadius = 10.dp, + onActivate = { onExpandedChange(!expanded) }, + highlightColor = NavHighlight, + tapToSelect = true + ) + .padding(vertical = 4.dp, horizontal = 4.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title.uppercase(), + color = accentColor, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + fontSize = 11.sp + ) + } + } + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(20.dp) + ) + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column( + modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + content() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/feature/settings/other/OtherSettingsFragment.kt b/app/src/main/feature/settings/other/OtherSettingsFragment.kt index 79fa30188..9b3e4b02a 100644 --- a/app/src/main/feature/settings/other/OtherSettingsFragment.kt +++ b/app/src/main/feature/settings/other/OtherSettingsFragment.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -13,7 +14,6 @@ import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -32,6 +32,7 @@ import com.winlator.cmod.feature.shortcuts.FrontendExporter import com.winlator.cmod.feature.setup.SetupWizardActivity import com.winlator.cmod.runtime.audio.midi.MidiManager import com.winlator.cmod.runtime.display.environment.ImageFsInstaller +import com.winlator.cmod.runtime.system.ProcessHelper import com.winlator.cmod.shared.ui.toast.WinToast import com.winlator.cmod.shared.android.DirectoryPickerDialog import com.winlator.cmod.shared.android.LocaleHelper @@ -171,6 +172,25 @@ class OtherSettingsFragment : Fragment() { }, onEnableBackgroundSessionChanged = { checked -> preferences.edit { putBoolean("enable_background_session", checked) } + // This is true if Store services are already running, but it isn't when is auto-enabled on wizard. + WinToast.show(ctx, R.string.settings_general_take_effect_next_startup) + refresh() + }, + onEnableAutoPauseChanged = { checked -> + 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 -> @@ -243,6 +263,12 @@ class OtherSettingsFragment : Fragment() { openInBrowser = preferences.getBoolean("open_with_android_browser", false), shareClipboard = preferences.getBoolean("share_android_clipboard", false), enableBackgroundSession = preferences.getBoolean("enable_background_session", false), + enableAutoPause = preferences.getBoolean("enable_auto_pause_when_background", false), + useBackgroundWakelock = preferences.getBoolean("enable_background_wakelock", false), + heartbeatFrequency = preferences.getInt("background_heartbeat_frequency", 0), + backgroundPauseMode = ProcessHelper.BackgroundPauseMode.fromPrefValue( + preferences.getString("background_pause_mode", ProcessHelper.BackgroundPauseMode.GAME_ONLY.prefValue) + ), externalDisplayOutput = preferences.getBoolean("external_display_output", false), imagefsInstallProgress = uiState.imagefsInstallProgress, ) diff --git a/app/src/main/feature/settings/other/OtherSettingsScreen.kt b/app/src/main/feature/settings/other/OtherSettingsScreen.kt index a98a38274..19d2497a0 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,13 +30,17 @@ 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.ArrowCircleDown 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 @@ -40,11 +50,16 @@ 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 @@ -60,17 +75,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 @@ -93,6 +113,7 @@ private val TextPrimary = Color(0xFFF0F4FF) private val TextSecondary = Color(0xFF7A8FA8) private val SettingsSliderHeight = 24.dp private const val SettingsSliderTrackScaleY = 0.72f +private val Error = Color(0xFFFF4444) // State data class OtherSettingsState( @@ -111,6 +132,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, ) @@ -155,6 +180,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, @@ -262,6 +291,56 @@ 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, + ) + + SectionLabel(stringResource(R.string.settings_other_section_integration), modifier = Modifier.padding(top = 8.dp)) + SettingsToggleCard( title = stringResource(R.string.session_drawer_output_to_display), subtitle = stringResource(R.string.settings_external_display_output_summary), @@ -270,15 +349,6 @@ fun OtherSettingsScreen( onCheckedChange = onExternalDisplayOutputChanged, ) - SectionLabel(stringResource(R.string.settings_other_section_integration), modifier = Modifier.padding(top = 8.dp)) - - SettingsToggleCard( - title = stringResource(R.string.settings_general_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_auto_scraping), subtitle = stringResource(R.string.settings_general_auto_scraping_summary), @@ -1055,3 +1125,306 @@ 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), + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(CardDark) + .border(1.dp, CardBorder, RoundedCornerShape(12.dp)), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + // Header Row - Clicking this toggles expansion + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .paneNavItem( + cornerRadius = 8.dp, + onActivate = { expanded = !expanded }, + highlightColor = NavHighlight, + tapToSelect = true, + ) + .padding(vertical = 4.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(IconBoxBg), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + tint = Accent, + modifier = Modifier.size(17.dp), + ) + } + Spacer(Modifier.width(13.dp)) + Text( + text = stringResource(R.string.settings_other_bg_pause_mode_title), + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + // Chevron icon indicating state + Icon( + imageVector = if (expanded) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(20.dp) + ) + } + + // Options list - Only visible when expanded + if (expanded) Spacer(Modifier.height(10.dp)) + options.forEach { (mode, titleRes, subtitleRes) -> + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) + .paneNavItem( + cornerRadius = 8.dp, + onActivate = { onModeChanged(mode) }, + highlightColor = NavHighlight, + tapToSelect = true, + ) + .padding(vertical = 4.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentMode == mode, + onClick = { onModeChanged(mode) }, + colors = RadioButtonDefaults.colors( + selectedColor = Accent, + unselectedColor = TextSecondary, + ), + // Let the Row handle the focus + modifier = Modifier.focusProperties { canFocus = false } + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(titleRes), + color = TextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(subtitleRes), + color = TextSecondary, + fontSize = 11.sp, + ) + } + } + } + } + } + } +} + +@Composable +private fun HeartbeatFrequencyCard( + currentFrequency: Int, + onFrequencyChanged: (Int) -> Unit, +) { + // Tracks expansion. False = collapsed by default. + var expanded by remember { mutableStateOf(false) } + // Raw text while the user is typing; committed as Int on Done/focus-loss. + var rawText by remember(currentFrequency) { mutableStateOf(currentFrequency.toString()) } + val focusManager = LocalFocusManager.current + + // Tracks internal focus to prevent redundant commit on initial composition/attach + var isFocused by remember { mutableStateOf(false) } + + // Derive the effective value and whether the current input is in error, + // so we can give the user immediate feedback without committing bad state. + // Use Long to handle Int overflow detection correctly + val parsedLong = rawText.trim().toLongOrNull() + val isOverflow = rawText.isNotEmpty() && (parsedLong == null || parsedLong > Int.MAX_VALUE) + val isTooSmall = parsedLong != null && parsedLong != 0L && parsedLong < 5 + val isError = isOverflow || isTooSmall + + val effectiveLabel = when { + rawText.isEmpty() || parsedLong == 0L -> stringResource(R.string.settings_other_bg_heartbeat_disabled) + isOverflow -> stringResource(R.string.settings_other_bg_heartbeat_effective, Int.MAX_VALUE) + isTooSmall -> stringResource(R.string.settings_other_bg_heartbeat_minimum) + else -> stringResource(R.string.settings_other_bg_heartbeat_effective, parsedLong!!.toInt()) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(CardDark) + .border(1.dp, CardBorder, RoundedCornerShape(12.dp)), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .paneNavItem( + cornerRadius = 8.dp, + onActivate = { expanded = !expanded }, + highlightColor = NavHighlight, + tapToSelect = true, + ) + .padding(vertical = 4.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(IconBoxBg), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Timer, + contentDescription = null, + tint = Accent, + modifier = Modifier.size(17.dp), + ) + } + Spacer(Modifier.width(13.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_other_bg_heartbeat_title), + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Text( + text = stringResource(R.string.settings_other_bg_heartbeat_subtitle), + color = TextSecondary, + fontSize = 11.sp, + ) + } + } + // Chevron icon + Icon( + imageVector = if (expanded) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(20.dp) + ) + } + // Collapsible content (Input + Supporting Text) + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column { + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = rawText, + onValueChange = { rawText = it.filter { c -> c.isDigit() } }, + singleLine = true, + isError = isError, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + }, + ), + 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 (isFocused && !focus.isFocused) { + commitFrequency(rawText, currentFrequency, onFrequencyChanged) + } + isFocused = focus.isFocused + }, + suffix = { Text("s", color = TextSecondary, fontSize = 13.sp) }, + supportingText = effectiveLabel.let { label -> + { + Text( + text = label, + color = if (isError) Error else TextSecondary, + fontSize = 11.sp, + ) + } + }, + ) + } + } + } + } +} + +// Extracted so both Done-action and focus-loss share identical clamping logic. +private fun commitFrequency(raw: String, current: Int, onFrequencyChanged: (Int) -> Unit) { + val trimmed = raw.trim() + if (trimmed.isEmpty()) { + if (current != 0) onFrequencyChanged(0) + return + } + + val parsedLong = trimmed.toLongOrNull() + val committed = when { + parsedLong == null || parsedLong > Int.MAX_VALUE -> Int.MAX_VALUE // Overflow -> clamp to Int.MAX + parsedLong == 0L -> 0 // revert to default on empty / non-numeric -> disabled + parsedLong < 5 -> 5 // clamp to minimum + else -> parsedLong.toInt() + } + + if (committed != current) { + onFrequencyChanged(committed) + } +} diff --git a/app/src/main/feature/setup/SetupWizardActivity.kt b/app/src/main/feature/setup/SetupWizardActivity.kt index 08592f8aa..e0197a7e0 100644 --- a/app/src/main/feature/setup/SetupWizardActivity.kt +++ b/app/src/main/feature/setup/SetupWizardActivity.kt @@ -139,6 +139,8 @@ import java.io.File import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin +import androidx.core.content.edit +import timber.log.Timber private data class Particle( val x: Float, @@ -533,6 +535,8 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { fallbackUrl = "https://github.com/nicholasx417/WinNative-Components/releases/download/Proton/Proton-10-arm64ec-coffincolors.wcp", ) + private val TAG = "SetupWizardActivity"; + private val storageGranted = mutableStateOf(false) private val notifGranted = mutableStateOf(false) private val notifDenied = mutableStateOf(false) @@ -593,7 +597,7 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { notifDenied.value = !granted if (granted) { backgroundSessionEnabled.value = true - prefs(this).edit().putBoolean("enable_background_session", true).apply() + prefs(this).edit { putBoolean("enable_background_session", true) } } else if (Build.VERSION.SDK_INT >= 33 && !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) ) { @@ -890,7 +894,24 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { storageGranted.value = hasStoragePermission() notifGranted.value = hasNotificationPermissionSilently() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Enable background protection by default on Android 13+. + if (!androidx.preference.PreferenceManager.getDefaultSharedPreferences(this).contains("enable_background_session")) { + androidx.preference.PreferenceManager.getDefaultSharedPreferences(this) + .edit { putBoolean("enable_background_session", true) } + Timber.d("Android 14+ detected, background session protection enabled") + } + } backgroundSessionEnabled.value = prefs(this).getBoolean("enable_background_session", false) + if (Build.VERSION.SDK_INT >= 36) { + Timber.d("Android 16+ detected") + // If wakeLock preference isn't saved, enable it by default on Android 16+. + if (!androidx.preference.PreferenceManager.getDefaultSharedPreferences(this).contains("enable_background_wakelock")) { + androidx.preference.PreferenceManager.getDefaultSharedPreferences(this).edit { + putBoolean("enable_background_wakelock", true) + } + Timber.d("Android 16+ wakeLock preference enabled") + } + } refreshWizardState() loadAdvancedProfiles() @@ -1009,7 +1030,7 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { private fun requestNotifications() { if (hasNotificationPermissionSilently()) { backgroundSessionEnabled.value = true - prefs(this).edit().putBoolean("enable_background_session", true).apply() + prefs(this).edit { putBoolean("enable_background_session", true) } return } diff --git a/app/src/main/feature/stores/epic/service/EpicService.kt b/app/src/main/feature/stores/epic/service/EpicService.kt index 20b9b230e..7879b5647 100644 --- a/app/src/main/feature/stores/epic/service/EpicService.kt +++ b/app/src/main/feature/stores/epic/service/EpicService.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.Intent import android.os.IBinder import com.winlator.cmod.BuildConfig -import com.winlator.cmod.R import com.winlator.cmod.app.PluviaApp import com.winlator.cmod.app.db.download.DownloadRecord import com.winlator.cmod.app.service.DownloadService @@ -17,7 +16,6 @@ import com.winlator.cmod.feature.stores.epic.data.EpicGameToken import com.winlator.cmod.feature.stores.epic.ui.util.SnackbarManager import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety import com.winlator.cmod.feature.stores.steam.data.DownloadInfo -import com.winlator.cmod.feature.stores.steam.data.LaunchInfo import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase import com.winlator.cmod.feature.stores.steam.enums.Marker import com.winlator.cmod.feature.stores.steam.events.AndroidEvent @@ -26,7 +24,6 @@ import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils import com.winlator.cmod.feature.stores.steam.utils.PrefManager import com.winlator.cmod.runtime.system.SessionKeepAliveService import com.winlator.cmod.shared.android.AppTerminationHelper -import com.winlator.cmod.shared.android.NotificationHelper import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import timber.log.Timber @@ -35,9 +32,31 @@ 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. +// 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 +73,7 @@ class EpicService : Service() { val isRunning: Boolean get() = instance != null + fun start(context: Context) { Timber.tag("EPIC").d("Starting service...") if (isRunning) { @@ -65,7 +85,8 @@ 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) + + startEpicService(context, intent) return } @@ -80,24 +101,35 @@ 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) + + startEpicService(context, intent) } fun triggerLibrarySync(context: Context) { Timber.tag("EPIC").i("Triggering manual library sync (bypasses throttle)") val intent = Intent(context, EpicService::class.java) intent.action = ACTION_MANUAL_SYNC - context.startForegroundService(intent) + startEpicService(context, intent) + } + + fun startEpicService(context: Context, intent: Intent) { + try { + // Just start as a normal service. KeepAliveService should protect this. + context.startService(intent) + } catch (e: Exception) { + Timber.e(e, "Failed to start EpicService") + } } fun stop() { 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 +1158,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 +1270,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 +1283,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 +1426,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..8cd778058 100644 --- a/app/src/main/feature/stores/gog/service/GOGService.kt +++ b/app/src/main/feature/stores/gog/service/GOGService.kt @@ -22,7 +22,6 @@ import com.winlator.cmod.feature.sync.google.GameSaveBackupManager.BackupResult import com.winlator.cmod.runtime.container.Container import com.winlator.cmod.runtime.system.SessionKeepAliveService import com.winlator.cmod.shared.android.AppTerminationHelper -import com.winlator.cmod.shared.android.NotificationHelper import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import timber.log.Timber @@ -34,9 +33,41 @@ 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. +// 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 +93,8 @@ 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) + + startGOGService(context, intent) return } @@ -77,24 +109,35 @@ 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) + + startGOGService(context, intent) } fun triggerLibrarySync(context: Context) { Timber.i("[GOGService] Triggering manual library sync (bypasses throttle)") val intent = Intent(context, GOGService::class.java) intent.action = ACTION_MANUAL_SYNC - context.startForegroundService(intent) + startGOGService(context, intent) + } + + fun startGOGService(context: Context, intent: Intent) { + try { + // Just start as a normal service. KeepAliveService should protect this. + context.startService(intent) + } catch (e: Exception) { + Timber.e(e, "Failed to start GOGService") + } } fun stop() { 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 +1483,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 +1587,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 +1600,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 +1675,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..2b9953313 100644 --- a/app/src/main/feature/stores/steam/SteamLoginActivity.kt +++ b/app/src/main/feature/stores/steam/SteamLoginActivity.kt @@ -1,6 +1,7 @@ package com.winlator.cmod.feature.stores.steam import android.os.Bundle -import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.viewModels import androidx.activity.compose.setContent import androidx.compose.animation.* import androidx.compose.animation.core.* @@ -45,9 +46,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.winlator.cmod.R import com.winlator.cmod.feature.stores.steam.enums.LoginResult import com.winlator.cmod.feature.stores.steam.enums.LoginScreen +import com.winlator.cmod.feature.stores.steam.service.SteamService import com.winlator.cmod.feature.stores.steam.ui.SteamLoginViewModel import com.winlator.cmod.feature.stores.steam.ui.components.QrCodeImage import com.winlator.cmod.feature.stores.steam.ui.data.UserLoginState +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 @@ -64,11 +67,16 @@ private val TextSecondary = Color(0xFF7A8FA8) private val DangerRed = Color(0xFFFF7A88) class SteamLoginActivity : FixedFontScaleComponentActivity() { + + private val viewModel: SteamLoginViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) 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") } @@ -87,12 +95,25 @@ class SteamLoginActivity : FixedFontScaleComponentActivity() { outline = CardBorder, ), ) { - val viewModel: SteamLoginViewModel = viewModel() LoginContent(viewModel) } } } + override fun onDestroy() { + // Manually unregister this service as reason. + SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM) + + // Stop the background SteamService if we are leaving the login screen + // without a successful login. This removes the "Steam service active" reason. + val state = viewModel.loginState.value + if (state.loginResult != LoginResult.Success) { + SteamService.Companion.stop() + } + + super.onDestroy() + } + // Root @Composable fun LoginContent(viewModel: SteamLoginViewModel) { @@ -115,6 +136,11 @@ class SteamLoginActivity : FixedFontScaleComponentActivity() { val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + // Ensure the system Back button finishes the activity properly + BackHandler { + finish() + } + Box( modifier = Modifier diff --git a/app/src/main/feature/stores/steam/events/EventDispatcher.kt b/app/src/main/feature/stores/steam/events/EventDispatcher.kt index 4394d4150..9895110d8 100644 --- a/app/src/main/feature/stores/steam/events/EventDispatcher.kt +++ b/app/src/main/feature/stores/steam/events/EventDispatcher.kt @@ -1,10 +1,13 @@ package com.winlator.cmod.feature.stores.steam.events +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList import kotlin.reflect.KClass sealed interface Event class EventDispatcher { - val listeners = mutableMapOf>, MutableList, *>>>>() + // Use ConcurrentHashMap and CopyOnWriteArrayList for thread safety across Main and IO threads + val listeners = ConcurrentHashMap>, MutableList, *>>>>() open class EventListener, T>( val listener: (E) -> T, @@ -36,7 +39,9 @@ class EventDispatcher { listener(event as E) }, once), ) - listeners.getOrPut(eventClass) { mutableListOf() }.add(typedListener as Pair, *>>) + // computeIfAbsent is atomic in ConcurrentHashMap + listeners.computeIfAbsent(eventClass) { CopyOnWriteArrayList() } + .add(typedListener as Pair, *>>) } inline fun , T> off(noinline listener: (E) -> T) { @@ -47,11 +52,10 @@ class EventDispatcher { } inline fun > clearAllListenersOf() { - val currentKeys = listeners.keys.toList() - for (key in currentKeys) { - if (key is E) { - listeners.remove(key) - } + val targetClass = E::class + // Correctly identify keys that are subclasses of the target event class + listeners.keys.removeIf { key -> + targetClass.java.isAssignableFrom(key.java) } } @@ -70,7 +74,8 @@ class EventDispatcher { null }, false) val typedListener = Pair(listener.toString(), eventListener as EventListener, *>) - listeners.getOrPut(eventClass) { mutableListOf() }.add(typedListener) + listeners.computeIfAbsent(eventClass) { CopyOnWriteArrayList() } + .add(typedListener) } fun offJava( @@ -89,9 +94,9 @@ class EventDispatcher { ): T? { val eventClass = E::class return listeners[eventClass]?.let { eventListeners -> + // CopyOnWriteArrayList iterator is safe for concurrent modification val results = eventListeners - .toList() .map { eventListener -> val result = eventListener.second.listener(event) if (result == null && Unit is T) Unit as T else result as T @@ -107,7 +112,6 @@ class EventDispatcher { return listeners[eventClass]?.let { eventListeners -> val results = eventListeners - .toList() .map { eventListener -> eventListener.second.listener(event) }.toTypedArray() diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 51d9325ac..28a83b68a 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 internal lateinit var notificationHelper: NotificationHelper + /*var notificationID = 1 + var preferences: SharedPreferences? = null*/ + /*internal var STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID = -3 // Previus default: 3 + internal val STEAM_CHAT_BG_RUNNING_NOTIFICATION_ID_NAME = "winnative.steamChat"*/ internal var _unifiedFriends: SteamUnifiedFriends? = null @@ -251,7 +242,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. + internal val familyGroupMembers = java.util.concurrent.CopyOnWriteArrayList() private val appTokens: ConcurrentHashMap = ConcurrentHashMap() @@ -3609,7 +3601,10 @@ class SteamService : Service() { fun start(context: Context) { try { 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") } @@ -3642,12 +3637,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 { @@ -3666,8 +3663,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 { @@ -4066,8 +4066,13 @@ class SteamService : Service() { _chatServiceEnabledFlow.value = PrefManager.chatServiceEnabled 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) @@ -4172,6 +4177,13 @@ class SteamService : Service() { } } + // Register Steam component in the master foreground service + if (isRunning && !isStopping) { + SessionKeepAliveService.startComponent(this, SessionKeepAliveService.COMPONENT_STEAM, "Connected") + // Bridge: Clear any registration from SteamLoginViewModel.retryConnection which uses application context + SessionKeepAliveService.stopComponent(applicationContext, SessionKeepAliveService.COMPONENT_STEAM) + } + return START_STICKY } @@ -4188,9 +4200,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() } @@ -4247,6 +4262,7 @@ class SteamService : Service() { runCatching { s.close() } } wnSession = null + SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM) clearValues() } @@ -4269,7 +4285,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 = @@ -4389,7 +4405,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)) @@ -4676,4 +4694,19 @@ class SteamService : Service() { val ticket = getEncryptedAppTicket(appId) ?: return null return Base64.encodeToString(ticket, Base64.NO_WRAP) } + + override fun onTimeout(startId: Int, fstype: Int) { + /* + * Note: This callback is unreachable at targetSdk 28 (requires API 34+). + * It is implemented for forward compatibility to ensure that if the system + * enforces a timeout, we stop the service gracefully (which triggers full cleanup). + */ + super.onTimeout(startId, fstype) + Timber.w("SteamService reached 6-hour limit for dataSync foreground service. Stopping gracefully.") + + // Unregister before stopping + SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM) + Companion.stop() + } + } diff --git a/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt b/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt index 83b21b4a5..fb5bf025a 100644 --- a/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt +++ b/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt @@ -241,13 +241,6 @@ internal fun SteamService.handleAppForegrounded() { // Cancel any pending suspend timer — the app is back, so the session must stay up regardless of how long it was minimized. backgroundIdleJob?.cancel() backgroundIdleJob = null - // Restore the quiet foreground notification and drop the background-chat one. - if (isRunning && !isStopping) { - runCatching { - startForeground(1, notificationHelper.createForegroundNotification("Steam Service is running")) - notificationHelper.cancelBackgroundRunning() - }.onFailure { Timber.w(it, "Failed to restore SteamService foreground notification") } - } if (!suspendedForBackground) return suspendedForBackground = false Timber.i("App foregrounded — waking the WN-Steam-Client session") @@ -264,15 +257,6 @@ internal fun SteamService.handleAppForegrounded() { /** App went to the background — arm the deferred suspend check. */ internal fun SteamService.handleAppBackgrounded() { appInForeground = false - if (PrefManager.chatStayRunningOnExit && isRunning && !isStopping) { - runCatching { - startForeground( - NotificationHelper.BACKGROUND_RUNNING_NOTIFICATION_ID, - notificationHelper.createBackgroundRunningNotification(), - ) - notificationHelper.cancel() - }.onFailure { Timber.w(it, "Failed to show Steam background-chat notification") } - } scheduleBackgroundSuspendCheck() } @@ -322,12 +306,13 @@ internal fun SteamService.maybeSuspendForBackground(): Boolean { picsGetProductInfoJob?.cancel() messagePollerJob?.cancel() wnSession?.let { s -> runCatching { s.disconnect() } } - 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: To ensure the following code works correctly, it is necessary to investigate why, + * when SteamService is stopped in the background without an active game session, SteamService + * resumes after 5–10 seconds. This behavior can be verified in the active services notification. + */ + // Commented out because Steam auto-start again in background after a few seconds. + /*runCatching { SessionKeepAliveService.stopComponent(this, SessionKeepAliveService.COMPONENT_STEAM) } + .onFailure { Timber.w(it, "Failed to remove SteamService foreground state during background") }*/ return true } @@ -510,6 +495,7 @@ internal fun SteamService.clearValues() { _unifiedFriends?.close() _unifiedFriends = null + familyGroupMembers.clear() isStopping = false retryAttempt = 0 diff --git a/app/src/main/feature/stores/steam/ui/SteamLoginViewModel.kt b/app/src/main/feature/stores/steam/ui/SteamLoginViewModel.kt index 5c886b77b..a90d5ae2e 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,9 +282,13 @@ 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") + // Safety: unregister if the service failed to even attempt starting + SessionKeepAliveService.stopComponent(context, SessionKeepAliveService.COMPONENT_STEAM) } }, 1000) } catch (e: Exception) { diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 9919ec9cb..f71691321 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1141,6 +1141,8 @@ Por ejemplo, META para la tecla META, \n No hay registros de depuración disponibles. Primero habilita las opciones de depuración e inicia un juego. Error al capturar los registros: %1$s Registra en un archivo los errores, advertencias y fallos del proceso de WinNative + Registro de depuración filtrado + Guarda los registros de la aplicación en un archivo, filtrados según lo configurado en el filtro de etiquetas. Emulación Subsistemas Herramientas @@ -1621,6 +1623,53 @@ Ruta instalada: La creación de la carpeta falló Actualizar + + + Registro de la aplicación + Activa el registro y muestra las opciones de registro + Logcat + Registro de motivo de salida + Registra por qué terminó el proceso de la aplicación + Registro de fallos + Guardar registros de fallos nativos (tombstone) + Registro de observación de eventos + Captura un registro del sistema centrado en los eventos de pausa/reanudación + Filtro de etiquetas de registro + etiquetas seleccionadas + Filtrar líneas de registro por texto… + Modo + Todas + Incluir + Excluir + Agregar etiqueta personalizada… + Ninguna etiqueta seleccionada + + Protección en segundo plano + Mantener la sesión activa en segundo plano + Modo de pausa en segundo plano + Pausar todos los procesos + Suspende todos los procesos — máximo ahorro de batería (puede causar problemas). + Pausar solo el juego + Suspende solo el proceso del juego activo; los demás procesos y servicios siguen ejecutándose. + Pausar todo excepto el juego + Suspende todos los procesos y servicios, pero mantiene activo el proceso del juego. + Pausar sesión automáticamente + Pausa automáticamente la sesión cuando la aplicación pasa a segundo plano o el dispositivo se bloquea. + Activar un wake lock de CPU para mejorar la protección en segundo plano + Evita que la CPU entre en suspensión profunda mientras la sesión está en pausa. Aumenta el consumo de batería, pero puede mejorar la persistencia y la estabilidad en dispositivos con gestión de batería agresiva. + Frecuencia del heartbeat (segundos) + Puede mejorar la protección de la aplicación en segundo plano. Un valor de 0 desactiva la función. Cuanto menor sea el valor, mayor será el consumo potencial de batería. + Heartbeat desactivado + El mínimo es 5 s — se ajustará al guardar + Se ejecuta cada %1$d s + + La sesión del contenedor está en pausa + Hay una sesión de contenedor en ejecución + Descargando e instalando componentes + Chat de Steam ejecutándose en segundo plano + : servicio activo + : servicios activos + y ReShade Aplica un efecto ReShade (.fx) a los juegos DXVK/VKD3D (Vulkan) mediante la capa vkBasalt integrada. Coloca cada efecto en su propia carpeta en Android/data/<package>/files/ReShade/. El ajuste de parámetros en vivo y el interruptor en el juego llegarán con la actualización de la capa de recarga en caliente. diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 7c2bc7ae8..41931e297 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -853,6 +853,8 @@ F.eks. META for META-tast, \n Ingen fejlfindingslogfiler tilgængelige. Aktiver fejlfindingsindstillinger først og start et spil. Kunne ikke indfange logfiler: %1$s Log WinNative-procesfejl, advarsler og nedbrud til fil + Filtreret fejlfindingslog + Gem appens logfiler i en fil, filtreret i henhold til det, der er konfigureret i tag-filteret. Emulering Undersystemer Værktøjer @@ -1623,6 +1625,53 @@ Installeret sti: Kør i baggrunden Hold chatten kørende, efter du afslutter WinNative Opdater + + + App-logning + Slå logning til, og vis logindstillingerne + Logcat + Log over lukningsårsag + Registrerer, hvorfor appens proces sidst blev afsluttet + Nedbrudslog + Gem native nedbruds-spor (tombstone) + Hændelsesovervågningslog + Optager en fokuseret systemlog omkring pause/genoptag-hændelser + Logtag-filter + tags valgt + Filtrer loglinjer efter tekst… + Tilstand + Alle + Inkluder + Ekskluder + Tilføj brugerdefineret tag… + Ingen tags valgt + + Baggrundsbeskyttelse + Hold sessionen i live i baggrunden + Pausetilstand i baggrunden + Sæt alle processer på pause + Suspenderer alle processer — maksimal batteribesparelse (kan give problemer). + Sæt kun spillet på pause + Suspenderer kun den aktive spilproces; andre processer og tjenester kører videre. + Sæt alt undtagen spillet på pause + Suspenderer alle processer og tjenester, men holder spilprocessen aktiv. + Automatisk pause af session + Sætter automatisk sessionen på pause, når appen er i baggrunden, eller enheden er låst. + Aktivér en CPU-wakelock for at forbedre baggrundsbeskyttelsen + Forhindrer CPU\'en i at gå i dyb dvale, mens sessionen er på pause. Øger batteriforbruget, men kan forbedre persistensen og stabiliteten på enheder med aggressiv batteristyring. + Heartbeat-frekvens (sekunder) + Kan forbedre beskyttelsen af appen i baggrunden. Værdien 0 deaktiverer funktionen. Jo lavere værdi, desto højere potentielt batteriforbrug. + Heartbeat deaktiveret + Minimum er 5 s — justeres ved gemning + Kører hver %1$d s + + Containersessionen er på pause + En containersession kører + Downloader og installerer komponenter + Steam-chat kører i baggrunden + : tjeneste aktiv + : tjenester aktive + og ReShade Anvend en ReShade-effekt (.fx) på DXVK/VKD3D-spil (Vulkan) via det indbyggede vkBasalt-lag. Læg hver effekt i sin egen mappe under Android/data/<package>/files/ReShade/. Justering af parametre i realtid og kontakten i spillet kommer med opdateringen af live-reload-laget. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 303dbfa8e..0983c038a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -853,6 +853,8 @@ Z. B. META für Meta-Taste, \n Keine Debug-Protokolle verfügbar. Bitte zuerst Debug-Optionen aktivieren und ein Spiel starten. Protokolle konnten nicht erfasst werden: %1$s WinNative-Prozessfehler, -Warnungen und -Abstürze in Datei protokollieren + Gefiltertes Debug-Protokoll + Speichert die Protokolle der App in einer Datei, gefiltert nach den Einstellungen im Tag-Filter. Emulation Subsysteme Werkzeuge @@ -1571,6 +1573,26 @@ Installierter Pfad: Benutzerdefinierter Wert Zum Zielen neigen (Ausrichtung) + + Anwendungsprotokollierung + Protokollierung aktivieren und die Protokolloptionen anzeigen + Logcat + Grund-für-Beendigung-Protokoll + Erfasst, warum der App-Prozess beendet wurde + Absturzprotokoll + 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 @@ -1623,6 +1645,34 @@ Installierter Pfad: Im Hintergrund ausführen Chat nach dem Beenden von WinNative weiterlaufen lassen Aktualisieren + + + Hintergrundschutz + Sitzung im Hintergrund aktiv halten + Pausenmodus im Hintergrund + Alle Prozesse pausieren + Hält alle Prozesse an — maximale Akku-Ersparnis (kann Probleme verursachen). + Nur das Spiel pausieren + Hält nur den aktiven Spielprozess an; alle anderen Prozesse und Dienste laufen weiter. + Alles außer dem Spiel pausieren + Hält alle Prozesse und Dienste an, lässt den Spielprozess aber aktiv. + Sitzung automatisch pausieren + Pausiert die Sitzung automatisch, wenn die App in den Hintergrund wechselt oder das Gerät gesperrt wird. + CPU-Wakelock aktivieren, um den Hintergrundschutz zu verbessern + Verhindert den CPU-Tiefschlaf, während die Sitzung pausiert ist. Erhöht den Akkuverbrauch, kann aber die Persistenz und Stabilität auf Geräten mit aggressivem Akku-Management verbessern. + Heartbeat-Frequenz (Sekunden) + Kann den Hintergrundschutz der App verbessern. Der Wert 0 deaktiviert die Funktion. Je niedriger der Wert, desto höher der mögliche Akkuverbrauch. + Heartbeat deaktiviert + Minimum ist 5 s — wird beim Speichern angepasst + Läuft alle %1$d s + + Container-Sitzung ist pausiert + Eine Container-Sitzung läuft + Komponenten werden heruntergeladen und installiert + Steam-Chat läuft im Hintergrund + : Dienst aktiv + : Dienste aktiv + und ReShade Wende über die integrierte vkBasalt-Ebene einen ReShade-Effekt (.fx) auf DXVK/VKD3D-Spiele (Vulkan) an. Lege jeden Effekt in einen eigenen Ordner unter Android/data/<package>/files/ReShade/. Live-Parameteranpassung und der In-Game-Schalter kommen mit dem Live-Reload-Ebenen-Update. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5cc622090..a85d08872 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -853,6 +853,8 @@ Ej. META para la tecla META, \n No hay registros de depuración disponibles. Activa primero las opciones de depuración e inicia un juego. Error al capturar registros: %1$s Registrar errores, advertencias y fallos del proceso de WinNative en archivo + Registro de depuración filtrado + Guarda los registros de la aplicación en un archivo, filtrados según lo configurado en el filtro de etiquetas. Emulación Subsistemas Herramientas @@ -1570,6 +1572,27 @@ Ruta instalada: Traer al frente Valor personalizado Inclinar para apuntar (orientación) + + + Registro de la aplicación + Activa el registro y muestra las opciones de registro + Logcat + Registro de razón de salida + Registra por qué terminó el proceso de la aplicación + Registro de fallos + 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 @@ -1622,6 +1645,34 @@ Ruta instalada: Ejecutar en segundo plano Mantener el chat activo después de salir de WinNative Actualizar + + + Protección en segundo plano + Mantener la sesión activa en segundo plano + Modo de pausa en segundo plano + Pausar todos los procesos + Suspende todos los procesos — máximo ahorro de batería (puede causar problemas). + Pausar solo el juego + Suspende solo el proceso del juego activo; los demás procesos y servicios siguen ejecutándose. + Pausar todo excepto el juego + Suspende todos los procesos y servicios, pero mantiene activo el proceso del juego. + Pausar sesión automáticamente + Pausa automáticamente la sesión cuando la aplicación pasa a segundo plano o el dispositivo se bloquea. + Activar un wake lock de CPU para mejorar la protección en segundo plano + Evita que la CPU entre en suspensión profunda mientras la sesión está en pausa. Aumenta el consumo de batería, pero puede mejorar la persistencia y la estabilidad en dispositivos con gestión de batería agresiva. + Frecuencia del heartbeat (segundos) + Puede mejorar la protección de la aplicación en segundo plano. Un valor de 0 desactiva la función. Cuanto menor sea el valor, mayor será el consumo potencial de batería. + Heartbeat desactivado + El mínimo es 5 s — se ajustará al guardar + Se ejecuta cada %1$d s + + La sesión del contenedor está en pausa + Hay una sesión de contenedor en ejecución + Descargando e instalando componentes + Chat de Steam ejecutándose en segundo plano + : servicio activo + : servicios activos + y ReShade Aplica un efecto ReShade (.fx) a los juegos DXVK/VKD3D (Vulkan) mediante la capa vkBasalt integrada. Coloca cada efecto en su propia carpeta en Android/data/<package>/files/ReShade/. El ajuste de parámetros en vivo y el interruptor en el juego llegarán con la actualización de la capa de recarga en caliente. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index f62d0a67c..278f3e246 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1141,6 +1141,8 @@ E.g. META for META-näppäin, \n Virheenkorjauslokeja ei ole saatavilla. Ota ensin virheenkorjausasetukset käyttöön ja käynnistä peli. Lokien kaappaus epäonnistui: %1$s Kirjaa WinNative-prosessin virheet, varoitukset ja kaatumiset tiedostoon + Suodatettu vianmääritysloki + Tallenna sovelluksen lokit tiedostoon, suodatettuna tunnistesuodattimen määritysten mukaisesti. Emulointi Alijärjestelmät Työkalut @@ -1621,6 +1623,53 @@ Asennuspolku: Kansion luonti epäonnistui Päivitä + + + Sovelluslokitus + Ota lokitus käyttöön ja näytä lokitusasetukset + Logcat + Sulkeutumissyyloki + Tallentaa syyn, miksi sovelluksen prosessi viimeksi päättyi + Kaatumisloki + Tallenna natiivien kaatumisten jäljet (tombstone) + Tapahtumien valvontaloki + Tallentaa kohdennetun järjestelmälokin keskeytys-/jatkamistapahtumien ympäriltä + Lokitunnisteiden suodatin + tunnistetta valittu + Suodata lokirivejä tekstillä… + Tila + Kaikki + Sisällytä + Jätä pois + Lisää oma tunniste… + Ei valittuja tunnisteita + + Taustasuojaus + Pidä istunto käynnissä taustalla + Taustan keskeytystila + Keskeytä kaikki prosessit + Pysäyttää kaikki prosessit — suurin virransäästö (voi aiheuttaa ongelmia). + Keskeytä vain peli + Pysäyttää vain aktiivisen pelin prosessin; muut prosessit ja palvelut jatkavat toimintaansa. + Keskeytä kaikki paitsi peli + Pysäyttää kaikki prosessit ja palvelut, mutta pitää pelin prosessin aktiivisena. + Keskeytä istunto automaattisesti + Keskeyttää istunnon automaattisesti, kun sovellus siirtyy taustalle tai laite lukitaan. + Ota käyttöön suorittimen wake lock taustasuojauksen parantamiseksi + Estää suoritinta siirtymästä syväuneen istunnon ollessa keskeytettynä. Lisää akun kulutusta, mutta voi parantaa pysyvyyttä ja vakautta laitteissa, joissa on aggressiivinen akunhallinta. + Heartbeat-taajuus (sekuntia) + Voi parantaa sovelluksen taustasuojausta. Arvo 0 poistaa ominaisuuden käytöstä. Mitä pienempi arvo, sitä suurempi mahdollinen akun kulutus. + Heartbeat pois käytöstä + Vähimmäisarvo on 5 s — korjataan tallennettaessa + Suoritetaan %1$d sekunnin välein + + Säiliöistunto on keskeytetty + Säiliöistunto on käynnissä + Ladataan ja asennetaan komponentteja + Steam-chat käynnissä taustalla + : palvelu käynnissä + : palvelut käynnissä + ja ReShade Käytä ReShade-tehostetta (.fx) DXVK/VKD3D-peleissä (Vulkan) sisäänrakennetun vkBasalt-kerroksen kautta. Sijoita jokainen tehoste omaan kansioonsa polkuun Android/data/<package>/files/ReShade/. Reaaliaikainen parametrien säätö ja pelinsisäinen kytkin tulevat live-reload-kerroksen päivityksen myötä. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e8865ddbc..870ad47bc 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -853,6 +853,8 @@ Par ex. META pour la touche META, \n Aucun journal de débogage disponible. Activez d\'abord les options de débogage puis lancez un jeu. Échec de la capture des journaux : %1$s Journaliser les erreurs, avertissements et plantages du processus WinNative dans un fichier + Journal de débogage filtré + Enregistrez les journaux de l\'application dans un fichier, filtrés selon la configuration du filtre de balises. Émulation Sous-systèmes Outils @@ -1570,6 +1572,27 @@ Chemin installé : Mettre au premier plan Valeur personnalisée Incliner pour viser (orientation) + + + Journalisation de l\'application + Activer la journalisation et afficher les options de journalisation + Logcat + Journal des raisons de sortie + Enregistre pourquoi le processus de l\'application s\'est terminé + Journal des plantages + 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 @@ -1622,6 +1645,34 @@ Chemin installé : Exécuter en arrière-plan Garder le chat actif après avoir quitté WinNative Actualiser + + + Protection en arrière-plan + Maintenir la session active en arrière-plan + Mode de pause en arrière-plan + Mettre en pause tous les processus + Suspend tous les processus — économie de batterie maximale (peut causer des problèmes). + Mettre en pause uniquement le jeu + Suspend uniquement le processus du jeu actif ; les autres processus et services continuent de fonctionner. + Tout mettre en pause sauf le jeu + Suspend tous les processus et services, mais garde le processus du jeu actif. + Pause automatique de la session + Met automatiquement la session en pause lorsque l\'application passe en arrière-plan ou que l\'appareil est verrouillé. + Activer un wake lock CPU pour renforcer la protection en arrière-plan + Empêche le processeur d\'entrer en veille profonde pendant que la session est en pause. Augmente la consommation de batterie, mais peut améliorer la persistance et la stabilité sur les appareils à gestion de batterie agressive. + Fréquence du heartbeat (secondes) + Peut améliorer la protection de l\'application en arrière-plan. Une valeur de 0 désactive la fonction. Plus la valeur est basse, plus la consommation de batterie potentielle est élevée. + Heartbeat désactivé + Le minimum est de 5 s — sera ajusté à l\'enregistrement + S\'exécute toutes les %1$d s + + La session du conteneur est en pause + Une session de conteneur est en cours + Téléchargement et installation de composants + Chat Steam actif en arrière-plan + " : service actif" + " : services actifs" + et ReShade Appliquez un effet ReShade (.fx) aux jeux DXVK/VKD3D (Vulkan) via la couche vkBasalt intégrée. Placez chaque effet dans son propre dossier sous Android/data/<package>/files/ReShade/. Le réglage des paramètres en direct et le bouton en jeu arriveront avec la mise à jour de la couche à rechargement à chaud. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 63edff4cd..ae85e6f71 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -963,6 +963,8 @@ कोई डिबग लॉग उपलब्ध नहीं है. पहले डिबगिंग विकल्प सक्षम करें और एक गेम लॉन्च करें। लॉग कैप्चर करने में विफल: %1$s फ़ाइल में WinNative प्रक्रिया त्रुटियाँ, चेतावनियाँ और क्रैश लॉग करें + फ़िल्टर किया गया डिबग लॉग + ऐप के लॉग को एक फ़ाइल में सहेजें, टैग फ़िल्टर में कॉन्फ़िगर की गई सेटिंग के अनुसार फ़िल्टर किया गया। एमुलेशन सब-सिस्टम टूल @@ -1558,6 +1560,53 @@ बैकग्राउंड में चलाएँ WinNative से बाहर निकलने के बाद चैट चालू रखें रीफ़्रेश + + + एप्लिकेशन लॉगिंग + लॉगिंग चालू करें और लॉगिंग विकल्प दिखाएँ + Logcat + बंद होने के कारण का लॉग + रिकॉर्ड करता है कि ऐप प्रोसेस पिछली बार क्यों बंद हुई + क्रैश लॉग + नेटिव क्रैश टॉम्बस्टोन ट्रेस सहेजें + इवेंट निगरानी लॉग + पॉज़/रिज़्यूम इवेंट के आसपास केंद्रित सिस्टम लॉग रिकॉर्ड करता है + लॉग टैग फ़िल्टर + टैग चुने गए + टेक्स्ट से लॉग पंक्तियाँ फ़िल्टर करें… + मोड + सभी + शामिल करें + बाहर रखें + कस्टम टैग जोड़ें… + कोई टैग नहीं चुना गया + + बैकग्राउंड सुरक्षा + बैकग्राउंड में सेशन चालू रखें + बैकग्राउंड पॉज़ मोड + सभी प्रोसेस पॉज़ करें + सभी प्रोसेस निलंबित करता है — अधिकतम बैटरी बचत (समस्याएँ हो सकती हैं)। + केवल गेम पॉज़ करें + केवल चालू गेम प्रोसेस निलंबित करता है; बाकी प्रोसेस और सेवाएँ चलती रहती हैं। + गेम को छोड़कर सब पॉज़ करें + सभी प्रोसेस और सेवाएँ निलंबित करता है, लेकिन गेम प्रोसेस चालू रखता है। + सेशन स्वतः पॉज़ करें + ऐप बैकग्राउंड में जाने या डिवाइस लॉक होने पर सेशन को स्वतः पॉज़ करता है। + बैकग्राउंड सुरक्षा बढ़ाने के लिए CPU वेक लॉक सक्षम करें + सेशन पॉज़ रहते हुए CPU को डीप स्लीप में जाने से रोकता है। बैटरी खपत बढ़ती है, लेकिन आक्रामक बैटरी प्रबंधन वाले डिवाइस पर स्थायित्व और स्थिरता बेहतर हो सकती है। + हार्टबीट आवृत्ति (सेकंड) + बैकग्राउंड में ऐप की सुरक्षा बेहतर कर सकता है। 0 मान इस सुविधा को बंद कर देता है। मान जितना कम होगा, बैटरी खपत उतनी अधिक हो सकती है। + हार्टबीट बंद है + न्यूनतम 5 से. है — सहेजते समय ठीक कर दिया जाएगा + हर %1$d से. पर चलता है + + कंटेनर सेशन पॉज़ है + एक कंटेनर सेशन चल रहा है + कॉम्पोनेन्ट डाउनलोड और इंस्टॉल हो रहे हैं + Steam चैट बैकग्राउंड में चल रही है + : सेवा सक्रिय है + : सेवाएँ सक्रिय हैं + और ReShade अंतर्निहित vkBasalt लेयर के माध्यम से DXVK/VKD3D (Vulkan) गेम पर ReShade प्रभाव (.fx) लागू करें। प्रत्येक प्रभाव को Android/data/<package>/files/ReShade/ के अंतर्गत उसके अपने फ़ोल्डर में रखें। लाइव पैरामीटर ट्यूनिंग और इन-गेम टॉगल लाइव-रीलोड लेयर अपडेट के साथ आएंगे। diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 99e2cf56c..091a4196c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -853,6 +853,8 @@ Ad es. META per il tasto META, \n Nessun log di debug disponibile. Attiva prima le opzioni di debug e avvia un gioco. Impossibile acquisire i log: %1$s Registra errori, avvisi e arresti anomali del processo WinNative su file + Log di debug filtrati + Salva i log dell\'app in un file, filtrati secondo quanto configurato nel filtro dei tag. Emulazione Sottosistemi Strumenti @@ -1570,6 +1572,27 @@ Percorso installato: Porta in primo piano Valore personalizzato Inclina per mirare (orientamento) + + + Registro applicazione + Attiva il registro e mostra le opzioni di registro + Logcat + Registro motivo uscita + Registra il motivo della terminazione del processo dell\'app + Registro errori + 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 @@ -1622,6 +1645,34 @@ Percorso installato: Esegui in background Mantieni la chat attiva dopo aver chiuso WinNative Aggiorna + + + Protezione in background + Mantieni la sessione attiva in background + Modalità di pausa in background + Metti in pausa tutti i processi + Sospende tutti i processi — massimo risparmio della batteria (può causare problemi). + Metti in pausa solo il gioco + Sospende solo il processo del gioco attivo; gli altri processi e servizi continuano a funzionare. + Metti in pausa tutto tranne il gioco + Sospende tutti i processi e i servizi, ma mantiene attivo il processo del gioco. + Pausa automatica della sessione + Mette automaticamente in pausa la sessione quando l\'app è in background o il dispositivo è bloccato. + Abilita un wake lock della CPU per migliorare la protezione in background + Impedisce alla CPU di entrare in sospensione profonda mentre la sessione è in pausa. Aumenta il consumo della batteria, ma può migliorare la persistenza e la stabilità sui dispositivi con gestione aggressiva della batteria. + Frequenza dell\'heartbeat (secondi) + Può migliorare la protezione dell\'app in background. Un valore di 0 disattiva la funzione. Più basso è il valore, maggiore è il potenziale consumo della batteria. + Heartbeat disattivato + Il minimo è 5 s — verrà corretto al salvataggio + Viene eseguito ogni %1$d s + + La sessione del contenitore è in pausa + È in esecuzione una sessione del contenitore + Download e installazione dei componenti in corso + Chat di Steam in esecuzione in background + : servizio attivo + : servizi attivi + e ReShade Applica un effetto ReShade (.fx) ai giochi DXVK/VKD3D (Vulkan) tramite il livello vkBasalt integrato. Metti ogni effetto in una propria cartella in Android/data/<package>/files/ReShade/. La regolazione dei parametri in tempo reale e l\'interruttore in gioco arriveranno con l\'aggiornamento del livello a ricaricamento immediato. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index f444592f9..45dfc2229 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1141,6 +1141,8 @@ 利用可能なデバッグログがありません。先にデバッグオプションを有効にしてゲームを起動してください。 ログの取得に失敗しました: %1$s WinNative プロセスのエラー、警告、クラッシュをファイルに記録します + フィルタされたデバッグログ + タグフィルタで設定された内容に従って、アプリのログをフィルタリングし、ファイルに保存します。 エミュレーション サブシステム ツール @@ -1621,6 +1623,53 @@ フォルダの作成に失敗しました 更新 + + + アプリのログ記録 + ログ記録を有効にしてログオプションを表示します + Logcat + 終了理由ログ + アプリのプロセスが前回終了した理由を記録します + クラッシュログ + ネイティブクラッシュの tombstone トレースを保存します + イベント監視ログ + 一時停止/再開イベント前後のシステムログを記録します + ログタグフィルター + 個のタグを選択中 + テキストでログ行をフィルター… + モード + すべて + 含める + 除外 + カスタムタグを追加… + タグが選択されていません + + バックグラウンド保護 + バックグラウンドでセッションを維持します + バックグラウンド一時停止モード + すべてのプロセスを一時停止 + すべてのプロセスを停止します — 最大限のバッテリー節約(問題が発生する可能性があります)。 + ゲームのみ一時停止 + 実行中のゲームプロセスのみ停止し、他のプロセスやサービスは動作し続けます。 + ゲーム以外をすべて一時停止 + すべてのプロセスとサービスを停止しますが、ゲームプロセスは動作し続けます。 + セッションの自動一時停止 + アプリがバックグラウンドに移行するか端末がロックされたとき、セッションを自動的に一時停止します。 + CPU ウェイクロックを有効にしてバックグラウンド保護を強化 + セッションの一時停止中に CPU のディープスリープを防ぎます。バッテリー消費は増えますが、電池管理が厳しい端末での持続性と安定性が向上する場合があります。 + ハートビート頻度(秒) + バックグラウンドでのアプリ保護を改善できます。0 を設定すると無効になります。値が小さいほどバッテリー消費が増える可能性があります。 + ハートビート無効 + 最小値は 5 秒です — 保存時に補正されます + %1$d 秒ごとに実行 + + コンテナセッションは一時停止中です + コンテナセッションが実行中です + コンポーネントをダウンロードしてインストール中 + Steam チャットがバックグラウンドで実行中 + : サービス実行中 + : サービス実行中 + ReShade 内蔵の vkBasalt レイヤーを介して DXVK/VKD3D(Vulkan)ゲームに ReShade エフェクト(.fx)を適用します。各エフェクトを Android/data/<package>/files/ReShade/ 以下の専用フォルダーに配置してください。ライブパラメーター調整とゲーム内トグルは、ライブリロードレイヤーの更新で提供されます。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 3d9dd0d1c..ff0d9db49 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -853,6 +853,8 @@ 사용 가능한 디버그 로그가 없습니다. 먼저 디버그 옵션을 활성화하고 게임을 실행하세요. 로그 캡처 실패: %1$s WinNative 프로세스 오류, 경고 및 충돌을 파일에 기록 + 필터링된 디버그 로그 + 앱의 로그를 파일에 저장합니다. 태그 필터에 설정된 대로 필터링됩니다. 에뮬레이션 하위 시스템 도구 @@ -1623,6 +1625,53 @@ 백그라운드에서 실행 WinNative를 종료한 후에도 채팅 유지 새로고침 + + + 애플리케이션 로깅 + 로깅을 켜고 로깅 옵션을 표시합니다 + Logcat + 종료 원인 로그 + 앱 프로세스가 마지막으로 종료된 이유를 기록합니다 + 충돌 로그 + 네이티브 충돌 tombstone 트레이스를 저장합니다 + 이벤트 감시 로그 + 일시정지/재개 이벤트 전후의 시스템 로그를 기록합니다 + 로그 태그 필터 + 개 태그 선택됨 + 텍스트로 로그 줄 필터링… + 모드 + 전체 + 포함 + 제외 + 사용자 지정 태그 추가… + 선택된 태그 없음 + + 백그라운드 보호 + 백그라운드에서 세션 유지 + 백그라운드 일시정지 모드 + 모든 프로세스 일시정지 + 모든 프로세스를 정지합니다 — 최대 배터리 절약 (문제가 발생할 수 있음). + 게임만 일시정지 + 실행 중인 게임 프로세스만 정지하고, 다른 프로세스와 서비스는 계속 실행됩니다. + 게임 제외 모두 일시정지 + 모든 프로세스와 서비스를 정지하지만 게임 프로세스는 계속 실행됩니다. + 세션 자동 일시정지 + 앱이 백그라운드로 전환되거나 기기가 잠기면 세션을 자동으로 일시정지합니다. + CPU 웨이크락을 활성화하여 백그라운드 보호 강화 + 세션이 일시정지된 동안 CPU의 딥슬립을 방지합니다. 배터리 사용량이 늘어나지만, 배터리 관리가 공격적인 기기에서 지속성과 안정성이 향상될 수 있습니다. + 하트비트 주기(초) + 백그라운드 앱 보호를 개선할 수 있습니다. 값이 0이면 기능이 비활성화됩니다. 값이 낮을수록 배터리 소모가 커질 수 있습니다. + 하트비트 비활성화됨 + 최솟값은 5초이며 저장 시 자동 조정됩니다 + %1$d초마다 실행 + + 컨테이너 세션이 일시정지됨 + 컨테이너 세션이 실행 중입니다 + 구성 요소 다운로드 및 설치 중 + Steam 채팅이 백그라운드에서 실행 중 + : 서비스 실행 중 + : 서비스 실행 중 + ReShade 내장된 vkBasalt 레이어를 통해 DXVK/VKD3D(Vulkan) 게임에 ReShade 효과(.fx)를 적용합니다. 각 효과를 Android/data/<package>/files/ReShade/ 아래의 자체 폴더에 넣으세요. 실시간 매개변수 조정 및 게임 내 토글은 라이브 리로드 레이어 업데이트와 함께 제공됩니다. diff --git a/app/src/main/res/values-no/strings.xml b/app/src/main/res/values-no/strings.xml index 144ab9a8e..c8c70fe1e 100644 --- a/app/src/main/res/values-no/strings.xml +++ b/app/src/main/res/values-no/strings.xml @@ -1141,6 +1141,8 @@ F.eks. META for META-tast, \n Ingen feilsøkingslogger tilgjengelig. Aktiver feilsøkingsalternativer først og start et spill. Kunne ikke fange logger: %1$s Logg WinNative-prosessfeil, advarsler og krasj til fil + Filtrert feilsøkingslogg + Lagre appens logger til en fil, filtrert i henhold til hva som er konfigurert i merkelappfilteret. Emulering Delsystemer Verktøy @@ -1621,6 +1623,53 @@ Installert bane: Oppretting av mappe mislyktes Oppdater + + + Applogging + Slå på logging og vis loggalternativene + Logcat + Logg over avslutningsårsak + Registrerer hvorfor appens prosess sist ble avsluttet + Krasjlogg + Lagre native krasj-spor (tombstone) + Hendelsesovervåkingslogg + Fanger en fokusert systemlogg rundt pause/gjenoppta-hendelser + Loggtagg-filter + tagger valgt + Filtrer logglinjer etter tekst… + Modus + Alle + Inkluder + Ekskluder + Legg til egendefinert tagg… + Ingen tagger valgt + + Bakgrunnsbeskyttelse + Hold økten i live i bakgrunnen + Pausemodus i bakgrunnen + Sett alle prosesser på pause + Suspenderer alle prosesser — maksimal batterisparing (kan gi problemer). + Sett bare spillet på pause + Suspenderer bare den aktive spillprosessen; andre prosesser og tjenester fortsetter å kjøre. + Sett alt unntatt spillet på pause + Suspenderer alle prosesser og tjenester, men holder spillprosessen aktiv. + Automatisk pause av økten + Setter økten automatisk på pause når appen går i bakgrunnen eller enheten låses. + Aktiver en CPU-wakelock for å styrke bakgrunnsbeskyttelsen + Hindrer CPU-en i å gå i dyp dvale mens økten er på pause. Øker batteriforbruket, men kan forbedre persistensen og stabiliteten på enheter med aggressiv batteristyring. + Heartbeat-frekvens (sekunder) + Kan forbedre beskyttelsen av appen i bakgrunnen. Verdien 0 deaktiverer funksjonen. Jo lavere verdi, desto høyere potensielt batteriforbruk. + Heartbeat deaktivert + Kjører hver %1$d s + Minimum er 5 s — justeres ved lagring + + Containerøkten er satt på pause + En containerøkt kjører + Laster ned og installerer komponenter + Steam-chat kjører i bakgrunnen + : tjeneste aktiv + : tjenester aktive + og ReShade Bruk en ReShade-effekt (.fx) på DXVK/VKD3D-spill (Vulkan) via det innebygde vkBasalt-laget. Legg hver effekt i sin egen mappe under Android/data/<package>/files/ReShade/. Justering av parametere i sanntid og bryteren i spillet kommer med oppdateringen av live-reload-laget. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 33f7e49df..62e07bb95 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -859,6 +859,8 @@ Np. META dla klawisza META, \n Brak dostępnych logów debugowania. Najpierw włącz opcje debugowania i uruchom grę. Nie udało się przechwycić logów: %1$s Zapisuj błędy, ostrzeżenia i awarie procesu WinNative do pliku + Filtrowany dziennik debugowania + Zapisz dzienniki aplikacji do pliku, przefiltrowane zgodnie z konfiguracją filtra tagów. Emulacja Podsystemy Narzędzia @@ -1628,6 +1630,53 @@ Zainstalowana ścieżka: Uruchom w tle Pozostaw czat aktywny po zamknięciu WinNative Odśwież + + + Rejestrowanie aplikacji + Włącz rejestrowanie i pokaż opcje rejestrowania + Logcat + Log przyczyny zamknięcia + Zapisuje, dlaczego proces aplikacji został ostatnio zakończony + Log awarii + Zapisuj ślady natywnych awarii (tombstone) + Log obserwacji zdarzeń + Rejestruje log systemowy wokół zdarzeń pauzy/wznowienia + Filtr tagów logu + wybranych tagów + Filtruj linie logu po tekście… + Tryb + Wszystkie + Uwzględnij + Wyklucz + Dodaj własny tag… + Nie wybrano tagów + + Ochrona w tle + Utrzymuj sesję aktywną w tle + Tryb pauzy w tle + Wstrzymaj wszystkie procesy + Wstrzymuje wszystkie procesy — maksymalna oszczędność baterii (może powodować problemy). + Wstrzymaj tylko grę + Wstrzymuje tylko proces aktywnej gry; pozostałe procesy i usługi działają dalej. + Wstrzymaj wszystko oprócz gry + Wstrzymuje wszystkie procesy i usługi, ale pozostawia proces gry aktywny. + Automatyczna pauza sesji + Automatycznie wstrzymuje sesję, gdy aplikacja przechodzi w tło lub urządzenie jest zablokowane. + Włącz wake lock CPU, aby wzmocnić ochronę w tle + Zapobiega przechodzeniu procesora w głęboki sen, gdy sesja jest wstrzymana. Zwiększa zużycie baterii, ale może poprawić trwałość i stabilność na urządzeniach z agresywnym zarządzaniem baterią. + Częstotliwość heartbeat (sekundy) + Może poprawić ochronę aplikacji w tle. Wartość 0 wyłącza funkcję. Im niższa wartość, tym większe potencjalne zużycie baterii. + Heartbeat wyłączony + Minimum to 5 s — zostanie skorygowane przy zapisie + Wykonuje się co %1$d s + + Sesja kontenera jest wstrzymana + Trwa sesja kontenera + Pobieranie i instalowanie komponentów + Czat Steam działa w tle + : usługa aktywna + : usługi aktywne + i ReShade Zastosuj efekt ReShade (.fx) do gier DXVK/VKD3D (Vulkan) za pomocą wbudowanej warstwy vkBasalt. Umieść każdy efekt w osobnym folderze w Android/data/<package>/files/ReShade/. Dostrajanie parametrów na żywo i przełącznik w grze pojawią się wraz z aktualizacją warstwy przeładowywanej na żywo. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 144328d65..0956649f9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -853,6 +853,8 @@ Ex. META para tecla META, \n Nenhum log de depuração disponível. Ative as opções de depuração primeiro e inicie um jogo. Falha ao capturar logs: %1$s Registrar erros, avisos e falhas do processo do WinNative em arquivo + Log de Depuração Filtrado + Salvar os logs do aplicativo em um arquivo, filtrados de acordo com o que está configurado no filtro de tags. Emulação Subsistemas Ferramentas @@ -1622,6 +1624,53 @@ Caminho instalado: Executar em segundo plano Manter o chat ativo após sair do WinNative Atualizar + + + Registro do aplicativo + Ativar o registro e mostrar as opções de registro + Logcat + Log de motivo de encerramento + Registra por que o processo do aplicativo foi encerrado da última vez + Log de falhas + Salvar logs de falhas nativas (tombstone) + Log de observação de eventos + Captura um log do sistema focado nos eventos de pausa/retomada + Filtro de tags de log + tags selecionadas + Filtrar linhas do log por texto… + Modo + Todas + Incluir + Excluir + Adicionar tag personalizada… + Nenhuma tag selecionada + + Proteção em segundo plano + Manter a sessão ativa em segundo plano + Modo de pausa em segundo plano + Pausar todos os processos + Suspende todos os processos — máxima economia de bateria (pode causar problemas). + Pausar apenas o jogo + Suspende apenas o processo do jogo ativo; os demais processos e serviços continuam em execução. + Pausar tudo, exceto o jogo + Suspende todos os processos e serviços, mas mantém o processo do jogo ativo. + Pausar sessão automaticamente + Pausa automaticamente a sessão quando o aplicativo vai para segundo plano ou o dispositivo é bloqueado. + Ativar um wake lock da CPU para reforçar a proteção em segundo plano + Impede que a CPU entre em suspensão profunda enquanto a sessão está pausada. Aumenta o consumo de bateria, mas pode melhorar a persistência e a estabilidade em aparelhos com gerenciamento agressivo de bateria. + Frequência do heartbeat (segundos) + Pode melhorar a proteção do aplicativo em segundo plano. Um valor de 0 desativa o recurso. Quanto menor o valor, maior o consumo potencial de bateria. + Heartbeat desativado + O mínimo é 5 s — será ajustado ao salvar + Executa a cada %1$d s + + A sessão do container está pausada + Há uma sessão de container em execução + Baixando e instalando componentes + Chat do Steam em execução em segundo plano + : serviço ativo + : serviços ativos + e ReShade Aplique um efeito ReShade (.fx) a jogos DXVK/VKD3D (Vulkan) por meio da camada vkBasalt integrada. Coloque cada efeito em sua própria pasta em Android/data/<package>/files/ReShade/. O ajuste de parâmetros ao vivo e o botão no jogo chegarão com a atualização da camada de recarregamento ao vivo. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 36753863d..43d9058ce 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1141,6 +1141,8 @@ Por ex. META para tecla META, \n Não há registos de depuração disponíveis. Ative primeiro as opções de depuração e inicie um jogo. Falha ao capturar os registos: %1$s Registar erros, avisos e falhas do processo do WinNative num ficheiro + Log de Depuração Filtrado + Salvar os logs do aplicativo em um arquivo, filtrados de acordo com o que está configurado no filtro de tags. Emulação Subsistemas Ferramentas @@ -1621,6 +1623,53 @@ Caminho instalado: A criação da pasta falhou Atualizar + + + Registo da aplicação + Ativar o registo e mostrar as opções de registo + Logcat + Registo do motivo de encerramento + Regista porque é que o processo da aplicação terminou da última vez + Registo de falhas + Guardar registos de falhas nativas (tombstone) + Registo de observação de eventos + Captura um registo do sistema centrado nos eventos de pausa/retoma + Filtro de etiquetas de registo + etiquetas selecionadas + Filtrar linhas do registo por texto… + Modo + Todas + Incluir + Excluir + Adicionar etiqueta personalizada… + Nenhuma etiqueta selecionada + + Proteção em segundo plano + Manter a sessão ativa em segundo plano + Modo de pausa em segundo plano + Pausar todos os processos + Suspende todos os processos — máxima poupança de bateria (pode causar problemas). + Pausar apenas o jogo + Suspende apenas o processo do jogo ativo; os restantes processos e serviços continuam em execução. + Pausar tudo exceto o jogo + Suspende todos os processos e serviços, mas mantém o processo do jogo ativo. + Pausar sessão automaticamente + Pausa automaticamente a sessão quando a aplicação passa para segundo plano ou o dispositivo é bloqueado. + Ativar um wake lock da CPU para reforçar a proteção em segundo plano + Impede que a CPU entre em suspensão profunda enquanto a sessão está em pausa. Aumenta o consumo de bateria, mas pode melhorar a persistência e a estabilidade em dispositivos com gestão agressiva de bateria. + Frequência do heartbeat (segundos) + Pode melhorar a proteção da aplicação em segundo plano. Um valor de 0 desativa a funcionalidade. Quanto menor o valor, maior o potencial consumo de bateria. + Heartbeat desativado + O mínimo é 5 s — será ajustado ao guardar + Executa a cada %1$d s + + A sessão do contentor está em pausa + Há uma sessão de contentor em execução + A transferir e instalar componentes + Chat do Steam em execução em segundo plano + : serviço ativo + : serviços ativos + e ReShade Aplique um efeito ReShade (.fx) a jogos DXVK/VKD3D (Vulkan) através da camada vkBasalt integrada. Coloque cada efeito na sua própria pasta em Android/data/<package>/files/ReShade/. O ajuste de parâmetros em tempo real e o botão no jogo chegarão com a atualização da camada de recarregamento em tempo real. diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 2577a0474..f3c81991f 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -853,6 +853,8 @@ De ex. META pentru tasta META, \n Nu sunt disponibile jurnale de depanare. Activează mai întâi opțiunile de depanare și lansează un joc. Capturarea jurnalelor a eșuat: %1$s Înregistrează erorile, avertismentele și blocările procesului WinNative în fișier + Jurnal de depanare filtrat + Salvaţi jurnalele aplicaţiei într-un fişier, filtrate conform configurării din filtrul de etichete. Emulare Subsisteme Instrumente @@ -1621,6 +1623,53 @@ Cale instalata: Rulează în fundal Menține chatul activ după ce ieși din WinNative Reîmprospătează + + + Jurnalizare aplicatie + Activeaza jurnalizarea si afiseaza optiunile de jurnalizare + Logcat + Jurnal motiv de închidere + Înregistrează de ce s-a încheiat ultima dată procesul aplicației + Jurnal de blocări + Salvează urmele native ale blocărilor (tombstone) + Jurnal de monitorizare a evenimentelor + Capturează un jurnal de sistem concentrat pe evenimentele de pauză/reluare + Filtru de etichete de jurnal + etichete selectate + Filtrează liniile de jurnal după text… + Mod + Toate + Include + Exclude + Adaugă etichetă personalizată… + Nicio etichetă selectată + + Protecție în fundal + Menține sesiunea activă în fundal + Mod de pauză în fundal + Pune pauză tuturor proceselor + Suspendă toate procesele — economie maximă de baterie (poate cauza probleme). + Pune pauză doar jocului + Suspendă doar procesul jocului activ; celelalte procese și servicii continuă să ruleze. + Pune pauză la tot, cu excepția jocului + Suspendă toate procesele și serviciile, dar menține procesul jocului activ. + Pauză automată a sesiunii + Pune automat sesiunea pe pauză când aplicația trece în fundal sau dispozitivul este blocat. + Activează un wake lock CPU pentru a întări protecția în fundal + Împiedică CPU-ul să intre în somn profund cât timp sesiunea este pe pauză. Crește consumul de baterie, dar poate îmbunătăți persistența și stabilitatea pe dispozitivele cu gestionare agresivă a bateriei. + Frecvența heartbeat (secunde) + Poate îmbunătăți protecția aplicației în fundal. Valoarea 0 dezactivează funcția. Cu cât valoarea este mai mică, cu atât consumul potențial de baterie este mai mare. + Heartbeat dezactivat + Minimul este 5 s — va fi corectat la salvare + Rulează la fiecare %1$d s + + Sesiunea containerului este pe pauză + O sesiune de container rulează + Se descarcă și se instalează componente + Chatul Steam rulează în fundal + : serviciu activ + : servicii active + și ReShade Aplică un efect ReShade (.fx) jocurilor DXVK/VKD3D (Vulkan) prin stratul vkBasalt integrat. Pune fiecare efect în propriul folder în Android/data/<package>/files/ReShade/. Reglarea parametrilor în timp real și comutatorul din joc vor sosi odată cu actualizarea stratului cu reîncărcare în timp real. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d63098f20..2be8ff57d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -924,6 +924,8 @@ Журналы отладки отсутствуют. Сначала включите параметры отладки и запустите игру. Не удалось записать журналы: %1$s Записывать ошибки процесса WinNative, предупреждения и сбои в файл. + Отфильтрованный журнал отладки + Сохранить журналы приложения в файл, отфильтрованные согласно настройкам фильтра тегов. Эмуляция Подсистемы Инструменты @@ -1528,6 +1530,53 @@ Работать в фоне Оставлять чат активным после выхода из WinNative Обновить + + + Журналирование приложения + Включить журналирование и показать параметры журналирования + Logcat + Журнал причин завершения + Записывать причину последнего завершения процесса приложения + Журнал сбоев + Сохранять трассировки нативных сбоев (tombstone) + Журнал наблюдения за событиями + Записывать системный журнал вокруг событий паузы/возобновления + Фильтр тегов журнала + тегов выбрано + Фильтровать строки журнала по тексту… + Режим + Все + Включать + Исключать + Добавить свой тег… + Теги не выбраны + + Защита в фоновом режиме + Сохранять сессию активной в фоновом режиме + Режим паузы в фоне + Приостанавливать все процессы + Приостанавливает все процессы — максимальная экономия батареи (возможны проблемы). + Приостанавливать только игру + Приостанавливает только процесс активной игры; остальные процессы и службы продолжают работать. + Приостанавливать всё, кроме игры + Приостанавливает все процессы и службы, но оставляет процесс игры активным. + Автопауза сессии + Автоматически приостанавливать сессию, когда приложение уходит в фон или устройство заблокировано. + Включить wake lock ЦП для усиления защиты в фоне + Не даёт ЦП уходить в глубокий сон, пока сессия на паузе. Увеличивает расход батареи, но может улучшить персистентность и стабильность на устройствах с агрессивной оптимизацией батареи. + Частота heartbeat (секунды) + Может улучшить защиту приложения в фоне. Значение 0 отключает функцию. Чем меньше значение, тем выше возможный расход батареи. + Heartbeat отключён + Минимум — 5 с; будет исправлено при сохранении + Выполняется каждые %1$d с + + Сессия контейнера приостановлена + Выполняется сессия контейнера + Загрузка и установка компонентов + Чат Steam работает в фоновом режиме + : служба активна + : службы активны + и ReShade Применяйте эффект ReShade (.fx) к играм DXVK/VKD3D (Vulkan) через встроенный слой vkBasalt. Поместите каждый эффект в отдельную папку в Android/data/<package>/files/ReShade/. Настройка параметров в реальном времени и переключатель в игре появятся с обновлением слоя с горячей перезагрузкой. diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 5a9dfd660..7c447bdf7 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1141,6 +1141,8 @@ T.ex. META för META-tangent, \n Inga felsökningsloggar tillgängliga. Aktivera felsökningsalternativ först och starta ett spel. Det gick inte att fånga loggar: %1$s Logga WinNative-processfel, varningar och krascher till fil + Filtrerad felsökningslogg + Spara appens loggar till en fil, filtrerade enligt vad som är konfigurerat i taggfiltren. Emulering Delsystem Verktyg @@ -1621,6 +1623,53 @@ Installerad sökväg: Det gick inte att skapa mappen Uppdatera + + + Applikationsloggning + Slå på loggning och visa loggalternativen + Logcat + Logg över avslutsorsak + Registrerar varför appens process senast avslutades + Kraschlogg + Spara native krasch-spår (tombstone) + Händelseövervakningslogg + Fångar en fokuserad systemlogg kring paus/återuppta-händelser + Loggtaggfilter + taggar valda + Filtrera loggrader efter text… + Läge + Alla + Inkludera + Exkludera + Lägg till egen tagg… + Inga taggar valda + + Bakgrundsskydd + Håll sessionen vid liv i bakgrunden + Pausläge i bakgrunden + Pausa alla processer + Suspenderar alla processer — maximal batteribesparing (kan orsaka problem). + Pausa endast spelet + Suspenderar endast den aktiva spelprocessen; övriga processer och tjänster fortsätter köras. + Pausa allt utom spelet + Suspenderar alla processer och tjänster men håller spelprocessen aktiv. + Pausa sessionen automatiskt + Pausar sessionen automatiskt när appen hamnar i bakgrunden eller enheten låses. + Aktivera ett CPU-wakelock för att stärka bakgrundsskyddet + Hindrar processorn från djupsömn medan sessionen är pausad. Ökar batteriförbrukningen men kan förbättra persistensen och stabiliteten på enheter med aggressiv batterihantering. + Heartbeat-frekvens (sekunder) + Kan förbättra skyddet av appen i bakgrunden. Värdet 0 inaktiverar funktionen. Ju lägre värde, desto högre potentiell batteriförbrukning. + Heartbeat inaktiverat + Minimum är 5 s — justeras vid sparande + Körs var %1$d:e sekund + + Behållarsessionen är pausad + En behållarsession körs + Laddar ner och installerar komponenter + Steam-chatt körs i bakgrunden + : tjänst aktiv + : tjänster aktiva + och ReShade Använd en ReShade-effekt (.fx) på DXVK/VKD3D-spel (Vulkan) via det inbyggda vkBasalt-lagret. Lägg varje effekt i sin egen mapp under Android/data/<package>/files/ReShade/. Justering av parametrar i realtid och växeln i spelet kommer med uppdateringen av live-reload-lagret. diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 048f4e759..46533ee8a 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1141,6 +1141,8 @@ ไม่มีบันทึกดีบัก เปิดใช้ตัวเลือกการดีบักก่อนแล้วเปิดเกม ไม่สามารถบันทึกได้: %1$s บันทึกข้อผิดพลาด คำเตือน และการขัดข้องของกระบวนการ WinNative ลงไฟล์ + บันทึกดีบั๊กที่กรองแล้ว + บันทึกบันทึกของแอปลงในไฟล์ โดยกรองตามการตั้งค่าในตัวกรองแท็ก การจำลอง ระบบย่อย เครื่องมือ @@ -1621,6 +1623,53 @@ สร้างโฟลเดอร์ล้มเหลว รีเฟรช + + + การบันทึกล็อกแอป + เปิดการบันทึกล็อกและแสดงตัวเลือกการบันทึกล็อก + Logcat + บันทึกสาเหตุการปิด + บันทึกว่าทำไมโปรเซสของแอปจึงถูกปิดครั้งล่าสุด + บันทึกการแครช + บันทึกร่องรอย tombstone ของการแครชแบบเนทีฟ + บันทึกเฝ้าดูเหตุการณ์ + เก็บบันทึกระบบเฉพาะช่วงเหตุการณ์หยุดชั่วคราว/ทำต่อ + ตัวกรองแท็กบันทึก + แท็กที่เลือก + กรองบรรทัดบันทึกตามข้อความ… + โหมด + ทั้งหมด + รวม + ยกเว้น + เพิ่มแท็กกำหนดเอง… + ไม่ได้เลือกแท็ก + + การป้องกันเบื้องหลัง + คงเซสชันให้ทำงานเมื่ออยู่เบื้องหลัง + โหมดหยุดชั่วคราวเบื้องหลัง + หยุดทุกโปรเซสชั่วคราว + ระงับทุกโปรเซส — ประหยัดแบตเตอรี่สูงสุด (อาจทำให้เกิดปัญหา) + หยุดเฉพาะเกมชั่วคราว + ระงับเฉพาะโปรเซสของเกมที่ทำงานอยู่ ส่วนโปรเซสและบริการอื่นยังทำงานต่อ + หยุดทุกอย่างชั่วคราวยกเว้นเกม + ระงับทุกโปรเซสและบริการ แต่ยังให้โปรเซสของเกมทำงานต่อ + หยุดเซสชันชั่วคราวอัตโนมัติ + หยุดเซสชันชั่วคราวโดยอัตโนมัติเมื่อแอปอยู่เบื้องหลังหรืออุปกรณ์ถูกล็อก + เปิดใช้ CPU wake lock เพื่อเสริมการป้องกันเบื้องหลัง + ป้องกันไม่ให้ CPU เข้าสู่โหมดหลับลึกขณะเซสชันถูกหยุดชั่วคราว เพิ่มการใช้แบตเตอรี่ แต่อาจช่วยเพิ่มความต่อเนื่องและความเสถียรบนอุปกรณ์ที่จัดการแบตเตอรี่แบบเข้มงวด + ความถี่ heartbeat (วินาที) + อาจช่วยเพิ่มการป้องกันแอปเบื้องหลัง ค่า 0 จะปิดฟีเจอร์นี้ ยิ่งค่าต่ำ ยิ่งอาจใช้แบตเตอรี่มากขึ้น + ปิด heartbeat แล้ว + ค่าต่ำสุดคือ 5 วินาที — จะถูกปรับเมื่อบันทึก + ทำงานทุก %1$d วินาที + + เซสชันคอนเทนเนอร์ถูกหยุดชั่วคราว + มีเซสชันคอนเทนเนอร์กำลังทำงาน + กำลังดาวน์โหลดและติดตั้งส่วนประกอบ + แชท Steam กำลังทำงานเบื้องหลัง + : บริการกำลังทำงาน + : บริการกำลังทำงาน + และ ReShade ใช้เอฟเฟกต์ ReShade (.fx) กับเกม DXVK/VKD3D (Vulkan) ผ่านเลเยอร์ vkBasalt ที่มาพร้อมในตัว วางเอฟเฟกต์แต่ละรายการไว้ในโฟลเดอร์ของตัวเองภายใต้ Android/data/<package>/files/ReShade/ การปรับพารามิเตอร์แบบเรียลไทม์และสวิตช์ในเกมจะมาพร้อมกับการอัปเดตเลเยอร์แบบโหลดซ้ำทันที diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index acd1fe4f0..cc03700fc 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1141,6 +1141,8 @@ E.g. META için META tuşu, \n Kullanılabilir hata ayıklama günlüğü yok. Önce hata ayıklama seçeneklerini etkinleştirin ve bir oyun başlatın. Günlükler yakalanamadı: %1$s WinNative işlem hatalarını, uyarılarını ve çökmelerini dosyaya kaydet + Filtrelenmiş Hata Ayıklama Günlüğü + Uygulamanın günlüklerini, etiket filtresinde ayarlananlara göre filtreleyerek bir dosyaya kaydet. Öykünme Alt Sistemler Araçlar @@ -1621,6 +1623,53 @@ Yüklü konum: Klasör oluşturma başarısız oldu Yenile + + + Uygulama günlüğü + Günlük kaydını açar ve günlük seçeneklerini gösterir + Logcat + Çıkış nedeni günlüğü + Uygulama işleminin son kapanma nedenini kaydeder + Çökme günlüğü + Yerel çökme (tombstone) izlerini kaydet + Olay izleme günlüğü + Duraklatma/devam etme olayları çevresinde odaklanmış bir sistem günlüğü yakalar + Günlük etiketi filtresi + etiket seçildi + Günlük satırlarını metne göre filtrele… + Mod + Tümü + Dahil et + Hariç tut + Özel etiket ekle… + Etiket seçilmedi + + Arka Plan Koruması + Arka plandayken oturumu canlı tut + Arka plan duraklatma modu + Tüm işlemleri duraklat + Tüm işlemleri askıya alır — maksimum pil tasarrufu (sorunlara yol açabilir). + Yalnızca oyunu duraklat + Yalnızca etkin oyun işlemini askıya alır; diğer işlemler ve hizmetler çalışmaya devam eder. + Oyun dışında her şeyi duraklat + Tüm işlemleri ve hizmetleri askıya alır, ancak oyun işlemini etkin tutar. + Oturumu otomatik duraklat + Uygulama arka plana geçtiğinde veya cihaz kilitlendiğinde oturumu otomatik olarak duraklatır. + Arka plan korumasını güçlendirmek için CPU uyanık kilidini etkinleştir + Oturum duraklatılmışken CPU\'nun derin uykuya geçmesini önler. Pil kullanımını artırır, ancak agresif pil yönetimi olan cihazlarda kalıcılığı ve kararlılığı iyileştirebilir. + Kalp atışı sıklığı (saniye) + Arka planda uygulama korumasını iyileştirebilir. 0 değeri özelliği devre dışı bırakır. Değer ne kadar düşükse olası pil tüketimi o kadar yüksek olur. + Kalp atışı devre dışı + En düşük değer 5 sn — kaydederken düzeltilecek + Her %1$d saniyede bir çalışır + + Konteyner oturumu duraklatıldı + Çalışan bir konteyner oturumu var + Bileşenler indiriliyor ve kuruluyor + Steam sohbeti arka planda çalışıyor + : hizmet etkin + : hizmetler etkin + ve ReShade Yerleşik vkBasalt katmanı aracılığıyla DXVK/VKD3D (Vulkan) oyunlarına bir ReShade efekti (.fx) uygulayın. Her efekti Android/data/<package>/files/ReShade/ altında kendi klasörüne yerleştirin. Canlı parametre ayarı ve oyun içi anahtar, canlı yeniden yükleme katmanı güncellemesiyle gelecek. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index aa2251751..a6a575c07 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -859,6 +859,8 @@ Немає доступних журналів налагодження. Спочатку увімкніть параметри налагодження та запустіть гру. Не вдалося захопити журнали: %1$s Записувати помилки, попередження та збої процесу WinNative у файл + Відфільтрований журнал налагодження + Зберегти журнали застосунка у файл, відфільтровані згідно налаштувань фільтра тегів. Емуляція Підсистеми Інструменти @@ -1628,6 +1630,53 @@ Працювати у фоні Залишати чат активним після виходу з WinNative Оновити + + + Журналювання застосунку + Увімкнути журналювання та показати параметри журналювання + Logcat + Журнал причин завершення + Записувати причину останнього завершення процесу застосунку + Журнал збоїв + Зберігати трасування нативних збоїв (tombstone) + Журнал спостереження за подіями + Записувати системний журнал навколо подій паузи/відновлення + Фільтр тегів журналу + тегів вибрано + Фільтрувати рядки журналу за текстом… + Режим + Усі + Включати + Виключати + Додати власний тег… + Теги не вибрано + + Захист у фоновому режимі + Зберігати сесію активною у фоновому режимі + Режим паузи у фоні + Призупиняти всі процеси + Призупиняє всі процеси — максимальна економія батареї (можливі проблеми). + Призупиняти лише гру + Призупиняє лише процес активної гри; інші процеси та служби продовжують працювати. + Призупиняти все, крім гри + Призупиняє всі процеси та служби, але залишає процес гри активним. + Автопауза сесії + Автоматично призупиняти сесію, коли застосунок переходить у фон або пристрій заблоковано. + Увімкнути wake lock ЦП для посилення фонового захисту + Не дає ЦП переходити в глибокий сон, поки сесія на паузі. Збільшує витрату батареї, але може покращити персистентність і стабільність на пристроях з агресивною оптимізацією батареї. + Частота heartbeat (секунди) + Може покращити фоновий захист застосунку. Значення 0 вимикає функцію. Що менше значення, то більша можлива витрата батареї. + Heartbeat вимкнено + Мінімум — 5 с; буде виправлено під час збереження + Виконується кожні %1$d с + + Сесію контейнера призупинено + Виконується сесія контейнера + Завантаження та встановлення компонентів + Чат Steam працює у фоновому режимі + : служба активна + : служби активні + і ReShade Застосовуйте ефект ReShade (.fx) до ігор DXVK/VKD3D (Vulkan) через вбудований шар vkBasalt. Помістіть кожен ефект в окрему папку в Android/data/<package>/files/ReShade/. Налаштування параметрів у реальному часі та перемикач у грі з’являться з оновленням шару з гарячим перезавантаженням. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index fb359c30b..8180f7fc5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -853,6 +853,8 @@ 没有可用的调试日志。请先启用调试选项并启动游戏。 无法捕获日志:%1$s 将 WinNative 进程错误、警告和崩溃记录到文件 + 已筛选调试日志 + 将应用的日志保存到文件中,按标签过滤器的配置进行过滤。 模拟 子系统 工具 @@ -1622,6 +1624,53 @@ 在后台运行 退出 WinNative 后保持聊天运行 刷新 + + + 应用日志记录 + 开启日志记录并显示日志选项 + Logcat + 退出原因日志 + 记录应用进程上次终止的原因 + 崩溃日志 + 保存原生崩溃 tombstone 跟踪 + 事件监视日志 + 记录暂停/恢复事件前后的系统日志 + 日志标签过滤器 + 个标签已选择 + 按文本过滤日志行… + 模式 + 全部 + 包含 + 排除 + 添加自定义标签… + 未选择标签 + + 后台保护 + 在后台保持会话运行 + 后台暂停模式 + 暂停所有进程 + 挂起所有进程 — 最大程度省电(可能导致问题)。 + 仅暂停游戏 + 仅挂起当前游戏进程;其他进程和服务继续运行。 + 暂停除游戏外的所有进程 + 挂起所有进程和服务,但保持游戏进程运行。 + 自动暂停会话 + 当应用进入后台或设备锁定时自动暂停会话。 + 启用 CPU 唤醒锁以增强后台保护 + 在会话暂停期间阻止 CPU 进入深度睡眠。会增加电量消耗,但可能提高在激进电池管理设备上的持续性和稳定性。 + 心跳频率(秒) + 可以改善应用的后台保护。0 表示禁用此功能。值越小,潜在耗电越高。 + 心跳已禁用 + 最小值为 5 秒 — 保存时将自动修正 + 每 %1$d 秒运行一次 + + 容器会话已暂停 + 有一个容器会话正在运行 + 正在下载并安装组件 + Steam 聊天正在后台运行 + :服务运行中 + :服务运行中 + ReShade 通过内置的 vkBasalt 层将 ReShade 效果(.fx)应用于 DXVK/VKD3D(Vulkan)游戏。将每个效果放入 Android/data/<package>/files/ReShade/ 下各自的文件夹中。实时参数调整和游戏内开关将随实时重载层更新一起提供。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 6312633aa..bd25a8576 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -853,6 +853,8 @@ 沒有可用的除錯日誌。請先啟用除錯選項並啟動遊戲。 無法擷取日誌:%1$s 將 WinNative 程序錯誤、警告與當機記錄到檔案 + 已篩選的除錯日誌 + 將應用程式的日誌儲存至檔案,並依標籤過濾器的設定進行篩選。 模擬 子系統 工具 @@ -1621,6 +1623,53 @@ 在背景執行 離開 WinNative 後保持聊天執行 重新整理 + + + 應用程式日誌記錄 + 開啟日誌記錄並顯示日誌選項 + Logcat + 結束原因日誌 + 記錄應用程式程序上次終止的原因 + 當機日誌 + 儲存原生當機 tombstone 追蹤 + 事件監視日誌 + 記錄暫停/繼續事件前後的系統日誌 + 日誌標籤篩選器 + 個標籤已選取 + 依文字篩選日誌行… + 模式 + 全部 + 包含 + 排除 + 新增自訂標籤… + 未選取標籤 + + 背景保護 + 在背景保持工作階段運作 + 背景暫停模式 + 暫停所有程序 + 暫停所有程序 — 最大程度省電(可能導致問題)。 + 僅暫停遊戲 + 僅暫停目前的遊戲程序;其他程序和服務繼續運作。 + 暫停除遊戲外的所有程序 + 暫停所有程序和服務,但保持遊戲程序運作。 + 自動暫停工作階段 + 當應用程式進入背景或裝置鎖定時,自動暫停工作階段。 + 啟用 CPU 喚醒鎖定以增強背景保護 + 在工作階段暫停期間防止 CPU 進入深度睡眠。會增加電池消耗,但可能提升在電池管理激進的裝置上的持續性與穩定性。 + 心跳頻率(秒) + 可以改善應用程式的背景保護。0 表示停用此功能。數值越小,潛在耗電越高。 + 心跳已停用 + 最小值為 5 秒 — 儲存時將自動修正 + 每 %1$d 秒執行一次 + + 容器工作階段已暫停 + 有一個容器工作階段正在執行 + 正在下載並安裝元件 + Steam 聊天正在背景執行 + :服務執行中 + :服務執行中 + ReShade 透過內建的 vkBasalt 層將 ReShade 效果(.fx)套用至 DXVK/VKD3D(Vulkan)遊戲。將每個效果放入 Android/data/<package>/files/ReShade/ 下各自的資料夾中。即時參數調整和遊戲內開關將隨即時重新載入層更新一起提供。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 364f9c208..97d777087 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1143,6 +1143,24 @@ E.g. META for META key, \n Wine Debug Channel Debug + Application logging + Turn on logging and show the logging options + Logcat + Exit reason log + Record why the app process last terminated + Crash log + 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 @@ -1180,6 +1198,8 @@ E.g. META for META key, \n No debug logs available. Enable debugging options first and launch a game. Failed to capture logs: %1$s Log WinNative process errors, warnings and crashes to file + Filtered Debug Log + Save the app’s logs to a file, filtered according to what is configured in the tag filter. Emulation Subsystems Tools @@ -1202,6 +1222,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 stay 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 @@ -1659,4 +1698,12 @@ Installed path: Rename failed Create folder failed Refresh + + Container session is paused + There is a container session running + Downloading and installing components + Steam chat running in background + ": service is active" + ": services are active" + and diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index b5429ae71..164f790dc 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -79,6 +79,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; @@ -187,6 +188,7 @@ import java.util.Locale; import java.util.Timer; import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicLong; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.concurrent.CountDownLatch; @@ -196,6 +198,7 @@ import cn.sherlock.com.sun.media.sound.SF2Soundbank; import static com.winlator.cmod.runtime.display.XServerDisplayUtils.*; +import timber.log.Timber; public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { private static final long STEAM_TERMINATION_GRACE_MS = 10000L; @@ -361,6 +364,8 @@ public void run() { } }; + private final Runnable stopEventWatchTask = LogManager::stopEventWatch; + private void fireDrawerStickDir(int dir) { if (drawerStateHolder == null) return; if (!drawerStateHolder.isPaneOpen()) { @@ -564,6 +569,8 @@ private static class ReshadeLiveEffect { private boolean isDarkMode; private boolean enableLogsMenu; + private boolean autoPauseContainer; + private static final String TAG = "XServerDisplayActivity"; private GuestProgramLauncherComponent guestProgramLauncherComponent; private EnvVars overrideEnvVars; @@ -1014,7 +1021,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); } } @@ -1106,7 +1113,7 @@ private void handleDebugInjectTap(Intent intent) { private void switchLaunchTargetAfterCleanup(Intent intent) { if (!switchLaunchInProgress.compareAndSet(false, true)) { - Log.d("XServerDisplayActivity", "Switch launch already in progress; ignoring duplicate target intent"); + LogManager.log(TAG, "Switch launch already in progress; ignoring duplicate target intent", this); return; } @@ -1123,7 +1130,7 @@ private void switchLaunchTargetAfterCleanup(Intent intent) { performForcedSessionCleanup("switch launch target"); runOnUiThread(() -> { if (isFinishing() || isDestroyed()) { - Log.w("XServerDisplayActivity", "Switch cleanup finished after activity was destroyed"); + LogManager.logW(TAG, "Switch cleanup finished after activity was destroyed", null, this); return; } setIntent(relaunchIntent); @@ -1138,7 +1145,11 @@ public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); AppUtils.hideSystemUI(this); AppUtils.keepScreenOn(this); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) { + + // SHOW THE CONTAINER/GAME ON TOP OF LOCK SCREEN + // Completely disabled, because the purpose of this code was to prevent the container + // from being killed by the OS, but it didn’t work. And it became a nuisance. + /*if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) { setShowWhenLocked(true); setTurnScreenOn(true); } else { @@ -1155,18 +1166,25 @@ public void onCreate(Bundle savedInstanceState) { km.requestDismissKeyguard(this, null); } } catch (Throwable t) { - Log.w("XServerDisplayActivity", + Log.w(TAG, "requestDismissKeyguard failed: " + t.getMessage()); } - } + }*/ + DebugFragment.Companion.cleanupSharedLogs(); 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); @@ -1188,7 +1206,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); @@ -1200,6 +1218,7 @@ public void onCreate(Bundle savedInstanceState) { preferences.edit().putBoolean("show_touchscreen_controls_enabled", true).apply(); 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); @@ -1252,7 +1271,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."); } }; @@ -1362,7 +1381,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); } } @@ -1434,13 +1453,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; } @@ -1550,12 +1569,12 @@ public void handleOnBackPressed() { if (newContainerId != container.id) { container = containerManager.getContainerById(newContainerId); if (container == null) { - Log.e("XServerDisplayActivity", "Failed to retrieve overridden container with ID: " + newContainerId); + LogManager.logE(TAG, "Failed to retrieve overridden container with ID: " + newContainerId, null, this); finish(); return; } if (!containerManager.activateContainer(container)) { - Log.e("XServerDisplayActivity", "Failed to activate overridden container with ID: " + newContainerId); + LogManager.logE(TAG, "Failed to activate overridden container with ID: " + newContainerId, null, this); finish(); return; } @@ -2623,8 +2642,10 @@ public void onResume() { handler.postDelayed(savePlaytimeRunnable, SAVE_INTERVAL_MS); if (!cleaningUp && !isPaused) { - ProcessHelper.resumeAllWineProcesses(); - SessionKeepAliveService.onResumeSession(this); + if (autoPauseContainer) { + // Move heavy proc-walk to background + new Thread(ProcessHelper::resumeAllWineProcesses, "WineProcessResumer").start(); + } } if (taskManagerPaneVisible && taskManagerTimer == null) { @@ -2632,10 +2653,28 @@ public void onResume() { } if (externalDisplayController != null) externalDisplayController.start(); + + SessionKeepAliveService.onResumeSession(this); + LogManager.log(TAG, "Session resumed", getApplicationContext()); + if (!isInPictureInPictureMode()) { + // Cancel any pending stop task and re-schedule it + handler.removeCallbacks(stopEventWatchTask); + handler.postDelayed(stopEventWatchTask, 8000); + } } @Override public void onPause() { + if (!isInPictureInPictureMode()) { + // Cancel the scheduled stop immediately so it doesn't kill + // the watcher we are about to start below. + handler.removeCallbacks(stopEventWatchTask); + // Move heavy proc-walk to background + new Thread(() -> LogManager.startEventWatch(getApplicationContext(), "XServerDisplayActivity.onPause"), "EventWatchStart").start(); + } + LogManager.log(TAG, "Session paused; entering background", getApplicationContext()); + SessionKeepAliveService.onPauseSession(this); + super.onPause(); isVolumeUpPressed = false; isVolumeDownPressed = false; @@ -2650,6 +2689,11 @@ public void onPause() { boolean cleaningUp = exitRequested.get() || sessionCleanupStarted.get() || activityDestroyed.get(); if (!cleaningUp && !isInPictureInPictureMode()) { + if (autoPauseContainer) { + // Move heavy proc-walk to background + new Thread(ProcessHelper::pauseAllWineProcesses, "WineProcessPauser").start(); + } + if (environment != null) { environment.onPause(); xServerView.onPause(); @@ -2676,6 +2720,12 @@ public void onPause() { if (externalDisplayController != null) externalDisplayController.stop(); } + @Override + public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); + SessionKeepAliveService.setPipMode(isInPictureInPictureMode); + } + @Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); @@ -2799,12 +2849,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 { @@ -2837,18 +2887,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; } @@ -2858,12 +2908,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"); } @@ -2877,29 +2927,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(); @@ -2908,15 +2958,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; } @@ -2983,14 +3033,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); } } @@ -3150,13 +3200,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) { @@ -3195,8 +3245,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 { @@ -3238,11 +3289,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; } @@ -3270,14 +3321,14 @@ private void exit() { ProcessHelper.drainDeadChildren("activity exit cleanup"); ProcessHelper.scheduleDeadChildReapSweep("activity exit cleanup", 4000, 200); if (!remaining.isEmpty()) { - Log.e("XServerDisplayActivity", "Exit cleanup still has remaining session processes: " + remaining); + Log.e(TAG, "Exit cleanup still has remaining session processes: " + remaining); } if (environment != null) { environment.stopEnvironmentComponents(); environment = null; } - Log.d("XServerDisplayActivity", "Process snapshot after environment stop: " - + ProcessHelper.listRunningWineProcessDetails()); + LogManager.log(TAG, "Process snapshot after environment stop: " + + ProcessHelper.listRunningWineProcessDetails(), this); stopXServer("exit"); wineRequestHandler = null; midiHandler = null; @@ -3300,13 +3351,43 @@ 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); + finish(); + 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(); } @@ -3969,6 +4050,7 @@ protected void onDestroy() { if (exitRequested.get()) { SessionKeepAliveService.stopSession(this); } + LogManager.stopEventWatch(); super.onDestroy(); if (!switchLaunchInProgress.get()) { @@ -3995,7 +4077,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"); @@ -5732,11 +5814,9 @@ private boolean handleDrawerAction(int itemId) { case R.id.main_menu_pause: if (isPaused) { ProcessHelper.resumeAllWineProcesses(); - SessionKeepAliveService.onResumeSession(this); } else { ProcessHelper.pauseAllWineProcesses(); - SessionKeepAliveService.onPauseSession(this); if (touchpadView != null) touchpadView.resetInputState(); if (inputControlsView != null) inputControlsView.cancelActiveTouches(); } @@ -6707,7 +6787,7 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { XEnvironment existingEnv = SessionKeepAliveService.getActiveEnvironment(); XServer existingXServer = SessionKeepAliveService.getActiveXServer(); if (existingEnv != null && existingXServer != null) { - Log.i("XServerDisplayActivity", "Re-attaching to existing background session environment"); + Log.i(TAG, "Re-attaching to existing background session environment"); this.environment = existingEnv; this.xServer = existingXServer; this.environment.setContext(this); @@ -6722,6 +6802,12 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { winHandler.markSessionInitialized(); this.guestProgramLauncherComponent = environment.getComponent(GuestProgramLauncherComponent.class); + + // Recovery sweep: ensure everything is resumed and protected after re-attaching. + // This prevents a frozen guest if the app was killed while the container was paused. + ProcessHelper.resumeAllWineProcesses(); + ProcessHelper.protectAllWineProcesses(); + return; } } @@ -6848,7 +6934,7 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { container.isNeedsUnpacking(), currentUnpackFiles); } catch (Exception e) { - Log.e("XServerDisplayActivity", "preUnpack failed", e); + Log.e(TAG, "preUnpack failed", e); } }); } else if ("GOG".equals(prereqGameSource) || "EPIC".equals(prereqGameSource)) { @@ -6857,7 +6943,7 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { installMonoIfNeeded(guestProgramLauncherComponent); installGeckoIfNeeded(guestProgramLauncherComponent); } catch (Exception e) { - Log.e("XServerDisplayActivity", "preUnpack failed", e); + Log.e(TAG, "preUnpack failed", e); } }); } @@ -7301,7 +7387,7 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { guestProgramLauncherComponent.setEnvVars(envVars); guestProgramLauncherComponent.setTerminationCallback((status) -> { - Log.d("XServerDisplayActivity", "Guest process terminated with status: " + status); + LogManager.log(TAG, "Guest process [" + guestProgramLauncherComponent.getGuestExecutable() + "] terminated with status: " + status, this); stopWnLauncherStatusTailer(); if (isDependencyInstall) { @@ -8498,7 +8584,6 @@ public boolean dispatchKeyEvent(KeyEvent event) { return true; } - if (event.getAction() == KeyEvent.ACTION_DOWN && (event.getKeyCode() == KeyEvent.KEYCODE_HOME || event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_SELECT)) { @@ -8512,8 +8597,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", @@ -9978,11 +10061,11 @@ private boolean installMonoIfNeeded(GuestProgramLauncherComponent launcher) { // Any installed Mono is kept as-is; prefix repair clears the marker to force a reinstall. String installedVersion = container.getExtra("mono_version", null); if (installedVersion != null) { - Log.d("XServerDisplayActivity", "Mono v" + installedVersion + " already installed in container " + container.id + ", skipping"); + Log.d(TAG, "Mono v" + installedVersion + " already installed in container " + container.id + ", skipping"); return true; } if (hasInstalledComponentPrefix("mono")) { - Log.d("XServerDisplayActivity", "Mono already installed via components in container " + container.id + ", skipping"); + Log.d(TAG, "Mono already installed via components in container " + container.id + ", skipping"); return true; } @@ -9990,13 +10073,13 @@ private boolean installMonoIfNeeded(GuestProgramLauncherComponent launcher) { String requiredVersion = SteamClientManager.detectRequiredMonoVersion(this, winePath); if (requiredVersion == null) { - Log.w("XServerDisplayActivity", "Could not detect required Mono version, skipping"); + Log.w(TAG, "Could not detect required Mono version, skipping"); return false; } String monoWinePath = SteamClientManager.getMonoMsiWinePath(this, winePath); if (monoWinePath == null) { - Log.w("XServerDisplayActivity", "Mono MSI not available (no internet?), will retry next launch"); + Log.w(TAG, "Mono MSI not available (no internet?), will retry next launch"); return false; } @@ -10006,22 +10089,22 @@ private boolean installMonoIfNeeded(GuestProgramLauncherComponent launcher) { java.util.regex.Pattern.compile("wine-mono-(\\d+\\.\\d+\\.\\d+)").matcher(monoWinePath); if (monoMsiMatcher.find()) actualVersion = monoMsiMatcher.group(1); if (!actualVersion.equals(requiredVersion)) { - Log.w("XServerDisplayActivity", "Mono fallback: required v" + requiredVersion + Log.w(TAG, "Mono fallback: required v" + requiredVersion + " but installing v" + actualVersion + " (" + monoWinePath + ")"); } try { - Log.d("XServerDisplayActivity", "Installing Wine Mono v" + actualVersion + Log.d(TAG, "Installing Wine Mono v" + actualVersion + " (" + monoWinePath + ") in container " + container.id + "..."); String monoCmd = "wine msiexec /i " + monoWinePath + " && wineserver -k"; launcher.execShellCommand(monoCmd); container.putExtra("mono_installed", "true"); container.putExtra("mono_version", actualVersion); container.saveData(); - Log.d("XServerDisplayActivity", "Mono v" + actualVersion + " installed in container " + container.id); + Log.d(TAG, "Mono v" + actualVersion + " installed in container " + container.id); return true; } catch (Exception e) { - Log.w("XServerDisplayActivity", "Mono msiexec failed, will retry next launch", e); + Log.w(TAG, "Mono msiexec failed, will retry next launch", e); return false; } } @@ -10037,12 +10120,12 @@ private boolean hasInstalledComponentPrefix(String prefix) { private void installGeckoIfNeeded(GuestProgramLauncherComponent launcher) { String installedGecko = container.getExtra("gecko_version", null); if (installedGecko != null) { - Log.d("XServerDisplayActivity", "Gecko v" + installedGecko + " already installed in container " + Log.d(TAG, "Gecko v" + installedGecko + " already installed in container " + container.id + ", skipping"); return; } if (hasInstalledComponentPrefix("gecko")) { - Log.d("XServerDisplayActivity", "Gecko already installed via components in container " + Log.d(TAG, "Gecko already installed via components in container " + container.id + ", skipping"); return; } @@ -10050,12 +10133,12 @@ private void installGeckoIfNeeded(GuestProgramLauncherComponent launcher) { java.util.List geckoWinePaths = SteamClientManager.getGeckoMsiWinePaths(this); if (geckoWinePaths.size() < 2) { - Log.w("XServerDisplayActivity", "Gecko MSIs not available (no internet?), will retry next launch"); + Log.w(TAG, "Gecko MSIs not available (no internet?), will retry next launch"); return; } try { - Log.d("XServerDisplayActivity", "Installing Wine Gecko v" + geckoVersion + Log.d(TAG, "Installing Wine Gecko v" + geckoVersion + " in container " + container.id + "..."); StringBuilder geckoCmd = new StringBuilder(); for (String p : geckoWinePaths) { @@ -10066,9 +10149,9 @@ private void installGeckoIfNeeded(GuestProgramLauncherComponent launcher) { launcher.execShellCommand(geckoCmd.toString()); container.putExtra("gecko_version", geckoVersion); container.saveData(); - Log.d("XServerDisplayActivity", "Gecko v" + geckoVersion + " installed in container " + container.id); + Log.d(TAG, "Gecko v" + geckoVersion + " installed in container " + container.id); } catch (Exception e) { - Log.w("XServerDisplayActivity", "Gecko msiexec failed, will retry next launch", e); + Log.w(TAG, "Gecko msiexec failed, will retry next launch", e); } } diff --git a/app/src/main/runtime/display/connector/Client.java b/app/src/main/runtime/display/connector/Client.java index 983b67bbf..888cfeaf6 100644 --- a/app/src/main/runtime/display/connector/Client.java +++ b/app/src/main/runtime/display/connector/Client.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.concurrent.atomic.AtomicBoolean; public class Client { public final ClientSocket clientSocket; @@ -12,13 +13,25 @@ public class Client { private Object tag; protected Thread pollThread; protected int shutdownFd; - protected boolean connected; + protected volatile boolean connected; // was a plain boolean; read/written cross-thread + + // Guards killConnection(): both this client's own poll thread (on EOF/IOException) + // and the connector's teardown thread (XConnectorEpoll.shutdown()) can call + // killConnection() for the same client concurrently. Without a single-entry + // gate, both run the full close sequence, double-closing shutdownFd/clientSocket.fd + // after the OS has already reassigned the number elsewhere (fdsan abort). + private final AtomicBoolean killing = new AtomicBoolean(false); public Client(XConnectorEpoll connector, ClientSocket clientSocket) { this.connector = connector; this.clientSocket = clientSocket; } + /** True the first time this is called for this client; false on every call after. */ + protected boolean markKillingOnce() { + return killing.compareAndSet(false, true); + } + public void createIOStreams() { if (inputStream != null || outputStream != null) return; inputStream = new XInputStream(clientSocket, connector.getInitialInputBufferCapacity()); diff --git a/app/src/main/runtime/display/connector/XConnectorEpoll.java b/app/src/main/runtime/display/connector/XConnectorEpoll.java index b794e761b..0674f4113 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,30 +141,89 @@ public Client getClient(int fd) { } } - public void killConnection(Client client) { - client.connected = false; - connectionHandler.handleConnectionShutdown(client); - if (multithreadedClients) { - if (Thread.currentThread() != client.pollThread) { - client.requestShutdown(); + public void killConnection(Client client, String reason) { + if (client == null) { + LogManager.logW(TAG, "killConnection called with null client; reason=" + reason); + return; + } + int fd = client.clientSocket != null ? client.clientSocket.fd : -1; + // Log immediately on entry: fd, reason, whether multithreadedClients - Commented out to avoid noise + // LogManager.logI(TAG, "killConnection entry: fd=" + fd + " reason=\"" + reason + "\" multithreadedClients=" + multithreadedClients); + + // Both the client's poll thread (on EOF) and the connector's thread (on shutdown) + // can call this. Only the winner performs the actual resource cleanup. + boolean wonRace = client.markKillingOnce(); + if (wonRace) { + // Remove from map FIRST to avoid FD-reuse races where a new connection on + // the same FD could be deleted by this cleanup while it's being accepted. + synchronized (connectedClients) { + connectedClients.remove(fd); + } + + client.connected = false; + connectionHandler.handleConnectionShutdown(client); + + if (multithreadedClients) { + // Signal the poll thread to exit its native loop (if we are not that thread) + if (client.pollThread != null && Thread.currentThread() != client.pollThread) { + client.requestShutdown(); + } + } else { + removeFdFromEpoll(epollFd, fd); + } + } + + + // BOTH winner and loser must join the thread to ensure it's dead before + // shutdown() proceeds (unless the current thread IS the poll thread). + if (multithreadedClients && client.pollThread != null && Thread.currentThread() != client.pollThread) { + // FIX: Timed join to avoid teardown hang if the poll thread is blocked + // behind a SIGSTOPped peer. The native socket now has SO_SNDTIMEO, + // so it should exit eventually. 5s grace period avoids ANRs. + boolean interrupted = Thread.interrupted(); + try { + long timeout = 5000; + long start = System.currentTimeMillis(); while (client.pollThread.isAlive()) { + long remaining = timeout - (System.currentTimeMillis() - start); + if (remaining <= 0) { + LogManager.logW(TAG, "killConnection: pollThread for fd=" + fd + " did not exit; proceeding to avoid ANR."); + break; + } try { client.pollThread.join(); } catch (InterruptedException e) { + interrupted = true; } } + } finally { + if (interrupted) { + // Restore interrupt status and log interruption + Thread.currentThread().interrupt(); + LogManager.logW(TAG, + "Interrupted while joining pollThread for fd=" + fd + " reason=\"" + reason + "\""); + } + } + client.pollThread = null; + } else removeFdFromEpoll(epollFd, fd); - client.pollThread = null; + // Resource cleanup only happens once, and only after ensuring the thread is joined. + if (wonRace) { + // Free direct byte buffers and ancillary FDs to avoid resource leak warnings + try { + client.releaseIOStreams(); + } catch (Exception e) { + LogManager.logW(TAG, "Exception releasing IO streams for fd=" + fd, e); } - 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(); - closeFd(client.clientSocket.fd); - synchronized (connectedClients) { - connectedClients.remove(client.clientSocket.fd); + try { + client.clientSocket.closeAncillaryFds(); + } catch (Exception e) { + LogManager.logW(TAG, "Exception closing ancillary FDs for fd=" + fd, e); + } + if (multithreadedClients) closeFd(client.shutdownFd); + closeFd(fd); + LogManager.log(TAG, "killConnection completed: fd=" + fd + " reason=\"" + reason + "\""); } } @@ -167,10 +231,13 @@ private void shutdown() { while (true) { Client client; synchronized (connectedClients) { - if (connectedClients.size() == 0) break; - client = connectedClients.valueAt(connectedClients.size() - 1); + int size = connectedClients.size(); + if (size == 0) break; + // Pop from the map here to prevent busy-spinning if killConnection returns early. + client = connectedClients.valueAt(size - 1); + connectedClients.removeAt(size - 1); } - killConnection(client); + 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..f3dfd3089 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/LogFileUtils.java b/app/src/main/runtime/system/LogFileUtils.java index 93ac4b9cc..53807fa2a 100644 --- a/app/src/main/runtime/system/LogFileUtils.java +++ b/app/src/main/runtime/system/LogFileUtils.java @@ -15,7 +15,7 @@ public static void setFilename(String file) { } public static File getLogFile(Context context) { - File logsDir = LogManager.getLogsDir(context); + File logsDir = LogManager.getLogsDir(context, false); String logFile = fileName.replaceAll("[^a-zA-Z0-9\\-_]", "_").toLowerCase() + "_" diff --git a/app/src/main/runtime/system/LogManager.kt b/app/src/main/runtime/system/LogManager.kt index 4d65d8d7a..e320be92b 100644 --- a/app/src/main/runtime/system/LogManager.kt +++ b/app/src/main/runtime/system/LogManager.kt @@ -1,24 +1,284 @@ 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.os.Build +import android.os.Handler +import android.os.Looper import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.ActivityCompat import androidx.preference.PreferenceManager +import com.winlator.cmod.app.config.SettingsConfig +import com.winlator.cmod.shared.io.FileUtils +import timber.log.Timber import java.io.Closeable import java.io.File +import java.util.Date +import java.util.Locale +import androidx.core.content.edit +import androidx.core.net.toUri +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter object LogManager { private const val TAG = "LogManager" + private const val APP_LOG_FILE = "app_filtered-logs.log" + private const val EXIT_REASONS_FILE = "exit_reasons.log" + private const val CRASH_FILE = "crash.log" + private const val POST_MORTEM_FILE = "post_mortem.log" + private var logcatProcess: Process? = null private var appLogProcess: Process? = null + private var eventWatchProcess: Process? = null + + private val logTimestampFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + + enum class Level(val prefix: String) { DEBUG("D"), INFO("I"), WARN("W"), ERROR("E") } + + enum class TagFilterMode { ALL, INCLUDE, EXCLUDE } + + // Fixed diagnostic baseline always present in an event-watch capture, + // independent of the app-tag filter — these are system components, not + // app classes, so they don't belong in the same selectable list. + // Key = display name / selectable tag; value = logcat priority level. + // Stored as a map so the priority suffix is only applied when building + // the filterspec, not shown in the UI. + private val BASELINE_SYSTEM_TAGS: Map = linkedMapOf( + "ActivityManager" to "I", + "ActivityTaskManager" to "I", + "OomAdjuster" to "I", + "lmkd" to "I", + "Process" to "I", + ) + + // Developer-curated tags that always appear in the selectable list, + // supplementing GeneratedLogTags (auto-discovered via Gradle) and + // user-added custom tags. Add entries here for tags that matter for + // debugging but may not be auto-discovered (e.g. tags in native code + // or tags used only in rarely-executed paths). + private val DEVELOPER_TAGS: Set = setOf( + "WinlatorLifecycle", + "OomProtectCheck", + "GuestProgramLauncherComponent", + "XServerLeakCheck", + ) + + private const val EVENT_WATCH_TIMEOUT_MS = 90 * 60 * 1000L // 1.5 hours + private val watchTimeoutHandler = Handler(Looper.getMainLooper()) + private val stopWatchTask = Runnable { + Timber.i("Event watch timeout reached. Stopping.") + stopEventWatch() + + // Auto-disable the toggle in preferences/UI just in case the user may have forgotten + // that the logger was enabled, to avoid wasting battery life. + appContext?.let { context -> + PreferenceManager.getDefaultSharedPreferences(context).edit { + putBoolean(PREF_ENABLE_EVENT_WATCH_LOG, false) + } + } + } + + + private const val PREF_ENABLE_APP_DEBUG = "enable_app_debug" + private const val PREF_ENABLE_FILTERED_LOG = "enable_filtered_logs" + private const val PREF_ENABLE_EXIT_REASON_LOG = "enable_exit_reason_log" + private const val PREF_ENABLE_CRASH_LOG = "enable_crash_log" + private const val PREF_ENABLE_EVENT_WATCH_LOG = "enable_event_watch_log" + private const val PREF_TAG_FILTER_MODE = "log_tag_filter_mode" + private const val PREF_SELECTED_TAGS = "app_debug_tags" + private const val PREF_CUSTOM_TAGS = "app_debug_custom_tags" + private const val PREF_LOGGED_EXIT_KEYS = "logged_exit_keys" + private const val PREF_POST_MORTEM_KEYS = "post_mortem_keys" + + // ── Cached state ────────────────────────────────────────────────── + // + // The whole point of this section: nothing below should ever hit + // SharedPreferences or resolve a URI on a per-log-call basis. Both are + // read once and kept current by a listener, so a disabled or filtered-out + // call costs one volatile-field read, not a disk lookup. + + @Volatile private var appContext: Context? = null + @Volatile + var cachedAppDebugEnabled = false + @Volatile + var cachedFilteredLogEnabled = false + @Volatile private var cachedExitReasonLogEnabled = false + @Volatile private var cachedCrashLogEnabled = false + @Volatile var cachedEventWatchEnabled = false + @Volatile private var cachedTagFilterMode = TagFilterMode.ALL + @Volatile private var cachedSelectedTags: Set = emptySet() + @Volatile private var cachedCustomTags: Set = emptySet() + + @Volatile private var manualTextFilter: String? = null + @Volatile private var manualTextFilterPattern: Regex? = null + + @Volatile private var cachedPrivateLogsDir: File? = null + @Volatile private var cachedExternalLogsDir: File? = null + + private var prefsListener: SharedPreferences.OnSharedPreferenceChangeListener? = null + + private val RELEVANT_KEYS = setOf( + PREF_ENABLE_APP_DEBUG, PREF_ENABLE_FILTERED_LOG, PREF_ENABLE_EXIT_REASON_LOG, + PREF_ENABLE_CRASH_LOG, PREF_ENABLE_EVENT_WATCH_LOG, PREF_TAG_FILTER_MODE, + PREF_SELECTED_TAGS, PREF_CUSTOM_TAGS, "winlator_path_uri", + ) + + /** Cheap, public, and the recommended guard for any genuinely expensive log message. */ + @JvmStatic + val isDebugEnabled: Boolean get() = cachedAppDebugEnabled || cachedFilteredLogEnabled || cachedEventWatchEnabled + @JvmStatic + val isEventWatchEnabled: Boolean get() = cachedEventWatchEnabled + + private var crashHandlerInitialized = false + + private fun resolveContext(context: Context?): Context? = context?.applicationContext ?: appContext + + // Every log file carries the date/time it was created, the same way SessionLogWriter names the + // box64/fex/wine logs. Resolved once per process so appends keep landing in the same file. + private val sessionStamp: String by lazy { + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss", Locale.US)) + } + private val stampedNames = java.util.concurrent.ConcurrentHashMap() + + /** + * Exit reasons that constitute an abnormal termination and trigger a + * post-mortem report. Add entries here to cover new cases to be auto-reported. + */ + @RequiresApi(Build.VERSION_CODES.R) + private val POST_MORTEM_REASONS: Set = setOf( + ApplicationExitInfo.REASON_ANR, + ApplicationExitInfo.REASON_CRASH, + ApplicationExitInfo.REASON_CRASH_NATIVE, + /*ApplicationExitInfo.REASON_SIGNALED, + ApplicationExitInfo.REASON_LOW_MEMORY, + ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE,*/ + ) + + /** + * Call once, ideally from PluviaApp.onCreate(), so every later call + * site — including ones with no Context of their own — has a fallback, + * 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 + } + + // Move heavy I/O processing (exit reasons, ANR traces, post-mortems) + // to a dedicated background thread to prevent ANRs in onCreate. + Thread({ + logLastExitReasons(app) + runPostMortemIfNeeded(app) // Capture previous exit reasons if toggle is enabled. + }, "LogManager-StartupIO").start() // Capture unexpected crashes at start if crash toggle is disabled. + } + + private fun refreshCaches(context: Context) { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + cachedAppDebugEnabled = prefs.getBoolean(PREF_ENABLE_APP_DEBUG, false) + cachedFilteredLogEnabled = prefs.getBoolean(PREF_ENABLE_FILTERED_LOG, false) + cachedExitReasonLogEnabled = prefs.getBoolean(PREF_ENABLE_EXIT_REASON_LOG, false) + cachedCrashLogEnabled = prefs.getBoolean(PREF_ENABLE_CRASH_LOG, false) + cachedEventWatchEnabled = prefs.getBoolean(PREF_ENABLE_EVENT_WATCH_LOG, false) + 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) + cachedPrivateLogsDir = resolveLogsDir(context, prefs) + cachedExternalLogsDir = resolveLogsDir(context, prefs, true) + } + + 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") + fun getLogsDir(context: Context, isExternal: Boolean = false): File { + if (isExternal) + cachedExternalLogsDir?.let { return it } + else + cachedPrivateLogsDir?.let { return it } + + val ctx = resolveContext(context) ?: return File(SettingsConfig.DEFAULT_WINLATOR_PATH, "logs").also { + // No context available anywhere yet (init() never called and none + // passed in) — fall back without caching, since we can't listen + // for preference changes without one. + if (!it.exists()) it.mkdirs() + } + + val dir = resolveLogsDir(ctx, PreferenceManager.getDefaultSharedPreferences(ctx), isExternal) + if (isExternal) cachedExternalLogsDir = dir else cachedPrivateLogsDir = dir + + Timber.tag(TAG).d("Logs dir: $dir") + + return dir + } + + private fun resolveLogsDir(context: Context, prefs: SharedPreferences, isExternal: Boolean = false): File { + val currentPath: File = if (isExternal) { + File( + resolvePathString( + prefs.getString("winlator_path_uri", null), + SettingsConfig.DEFAULT_WINLATOR_PATH, + context + ) + ) + } else { + context.getExternalFilesDir(null) ?: context.filesDir + } + + Timber.d("Winnative pathString: $currentPath") + + 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 = uriStr.toUri() + FileUtils.getFilePathFromUri(ctx, uri) ?: fallback + } catch (e: Exception) { + logW(TAG,e, ctx) { "Failed to resolve winlator_path_uri ($uriStr): ${e.message}" } + fallback + } + } + fun isAnyLoggingEnabled(context: Context): Boolean { val prefs = PreferenceManager.getDefaultSharedPreferences(context) return prefs.getBoolean("enable_wine_debug", false) || @@ -26,7 +286,11 @@ 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) + cachedFilteredLogEnabled || + cachedEventWatchEnabled || + cachedExitReasonLogEnabled || + cachedCrashLogEnabled || + cachedAppDebugEnabled } fun updateLoggingState(context: Context) { @@ -35,6 +299,8 @@ object LogManager { } } + // Container boot only (XServerDisplayActivity), never app startup: a booting session starts + // from clean logs, while a plain cold start keeps whatever the previous runs wrote. @JvmStatic fun prepareForNewSession(context: Context) { stopAppLogging() @@ -43,6 +309,107 @@ object LogManager { startAppLogging(context, reset = true) } + private fun stamped(baseName: String): String { + return stampedNames.getOrPut(baseName) { + val dot = baseName.lastIndexOf('.') + if (dot > 0) { + "${baseName.substring(0, dot)}_$sessionStamp${baseName.substring(dot)}" + } else { + "${baseName}_$sessionStamp.log" + } + } + } + + /** True when any run's copy of [baseName] exists, since the name now carries a timestamp. */ + private fun anyLogExists(logsDir: File, baseName: String): Boolean { + val dot = baseName.lastIndexOf('.') + val prefix = if (dot > 0) baseName.substring(0, dot) else baseName + return logsDir.listFiles()?.any { it.isFile && it.name.startsWith("${prefix}_") } == true + } + + // ── Tag management (settings UI surface) ────────────────────────── + + /** Union of build-time-discovered tags and user-added custom ones, sorted for display. */ + @JvmStatic + fun getAllKnownTags(): List = + (GeneratedLogTags.TAGS + DEVELOPER_TAGS + BASELINE_SYSTEM_TAGS.keys + cachedCustomTags) + .distinct() + .sorted() + + @JvmStatic + fun getCachedCustomTags(): List = cachedCustomTags.sorted() + + @JvmStatic + fun addCustomTag(context: Context, tag: String) { + val cleaned = tag.trim() + if (cleaned.isEmpty()) return + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val updated = cachedCustomTags + cleaned + prefs.edit { putString(PREF_CUSTOM_TAGS, updated.joinToString(",")) } + } + + @JvmStatic + fun removeCustomTag(context: Context, tag: String) { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val updatedCustomTags = cachedCustomTags - tag + val updatedSelectedTags = cachedSelectedTags - tag // deselect too — a removed tag can't stay selected + prefs.edit { + putString(PREF_CUSTOM_TAGS, updatedCustomTags.joinToString(",")) + .putString(PREF_SELECTED_TAGS, updatedSelectedTags.joinToString(",")) + } + } + + @JvmStatic + fun setSelectedTags(context: Context, tags: Set) { + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(PREF_SELECTED_TAGS, tags.joinToString(",")) + } + } + + @JvmStatic + fun getSelectedTags(): Set = cachedSelectedTags + + @JvmStatic + fun setTagFilterMode(context: Context, mode: TagFilterMode) { + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(PREF_TAG_FILTER_MODE, mode.name) + } + } + + @JvmStatic + fun getTagFilterMode(): TagFilterMode = cachedTagFilterMode + + @JvmStatic + fun getSystemTags(): Set = BASELINE_SYSTEM_TAGS.keys.toSet() + + /** Transient only — never written to SharedPreferences. Pass null/blank to clear. */ + @JvmStatic + fun setManualTextFilter(text: String?) { + val input = text?.trim()?.takeIf { it.isNotEmpty() } + manualTextFilter = input + manualTextFilterPattern = input?.let { + try { + // Attempt to compile as a case-insensitive regex + Regex(it, RegexOption.IGNORE_CASE) + } catch (e: Exception) { + // If regex is invalid (e.g. "C++"), treat as a literal by escaping metacharacters + Regex(Regex.escape(it), RegexOption.IGNORE_CASE) + } + } + } + + @JvmStatic + 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) { @@ -51,8 +418,7 @@ object LogManager { return } - val logsDir = getLogsDir(context) - val logFile = File(logsDir, "logcat.log") + val logFile = File(getLogsDir(context), stamped("logcat.log")) try { stopLogcat() @@ -63,7 +429,7 @@ object LogManager { ) closeProcessStdin(logcatProcess) } catch (e: Exception) { - Log.e(TAG, "Failed to start logcat: ${e.message}") + logE(TAG,e, context) { "Failed to start logcat: ${e.message}" } } } @@ -77,23 +443,21 @@ object LogManager { logcatProcess?.let(::destroyProcess) logcatProcess = null } catch (e: Exception) { - Log.e(TAG, "Failed to stop logcat: ${e.message}") + logE(TAG,e) { "Failed to stop logcat: ${e.message}" } } } fun clearLogs(context: Context) { - val logsDir = getLogsDir(context) - logsDir.listFiles()?.forEach { it.delete() } + // Clean both directories + getLogsDir(context, true).listFiles()?.forEach { it.delete() } + getLogsDir(context, false).listFiles()?.forEach { it.delete() } } @JvmStatic @JvmOverloads fun startAppLogging(context: Context, reset: Boolean = false) { - 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), stamped("application.log")) try { stopAppLogging() @@ -107,9 +471,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}") + logE(TAG,e) { "Failed to start application logging: ${e.message}" } } } @@ -119,7 +483,7 @@ object LogManager { appLogProcess?.let(::destroyProcess) appLogProcess = null } catch (e: Exception) { - Log.e(TAG, "Failed to stop application logging: ${e.message}") + logE(TAG,e) { "Failed to stop application logging: ${e.message}" } } } @@ -152,19 +516,589 @@ object LogManager { @JvmStatic fun getShareableLogFiles(context: Context): Array { - val logsDir = getLogsDir(context) - return logsDir - .listFiles() - ?.filter { - it.isFile && (it.name.endsWith(".log") || it.name.endsWith(".txt") || it.name.endsWith(".csv")) - }?.toTypedArray() ?: emptyArray() + val filter: (File) -> Boolean = { + it.isFile && (it.name.endsWith(".log") || it.name.endsWith(".txt") || it.name.endsWith(".csv")) + } + // Aggregate files from both the internal and external directories + val external = getLogsDir(context, true).listFiles()?.filter(filter) ?: emptyList() + val private = getLogsDir(context, false).listFiles()?.filter(filter) ?: emptyList() + return (external + private).toTypedArray() } /** Total bytes of all shareable log files. */ @JvmStatic - fun getShareableLogsSize(context: Context): Long = getShareableLogFiles(context).sumOf { it.length() } + fun getShareableLogsSize(context: Context): Long { + // Sum sizes from both internal and external directories + val internalSize = getLogsDir(context, false).walk().filter { it.isFile }.sumOf { it.length() } + val externalSize = getLogsDir(context, true).walk().filter { it.isFile }.sumOf { it.length() } + return internalSize + externalSize + } /** Deletes all shareable log files; returns the count removed. */ @JvmStatic fun deleteShareableLogs(context: Context): Int = getShareableLogFiles(context).count { it.delete() } + + // ── 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. + + // ToDo: For the TAG filters to work across the entire app, the following methods must be used to replace Timber or Log lines. + + @JvmStatic @JvmOverloads + fun log(tag: String, message: String, context: Context? = null) = + baseLog(Level.DEBUG, tag, message, null, context) + + @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 && !cachedFilteredLogEnabled && !cachedEventWatchEnabled) return + baseLog(Level.DEBUG, tag, message(), null, context) + } + + inline fun logI(tag: String, context: Context? = null, message: () -> String) { + if (!cachedAppDebugEnabled && !cachedFilteredLogEnabled && !cachedEventWatchEnabled) return + baseLog(Level.INFO, tag, message(), null, context) + } + + inline fun logW(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) { + if (!cachedAppDebugEnabled && !cachedFilteredLogEnabled && !cachedEventWatchEnabled) return + baseLog(Level.WARN, tag, message(), t, context) + } + + inline fun logE(tag: String, t: Throwable? = null, context: Context? = null, message: () -> String) { + if (!cachedAppDebugEnabled && !cachedFilteredLogEnabled && !cachedEventWatchEnabled) return + baseLog(Level.ERROR, tag, message(), t, context) + } + + fun baseLog(level: Level, tag: String, message: String, t: Throwable?, context: Context?) { + val hasTree = Timber.forest().isNotEmpty() + + // Mirrors Timber Log so this can drop in for Log.* call sites. + when (level) { + Level.DEBUG -> Timber.tag(tag).d(message) + Level.INFO -> Timber.tag(tag).i(message) + Level.WARN -> { + if (hasTree) { + if (t != null) Timber.tag(tag).w(t, message) else Timber.tag(tag).w(message) + } + else { + if (t != null) Log.w(tag, message, t) else Log.w(tag, message) + } + } + Level.ERROR -> { + if (hasTree) { + if (t != null) Timber.tag(tag).e(t, message) else Timber.tag(tag).e(message) + } + else { // Fallback to Android Log for when Timber is not available + if (t != null) Log.e(tag, message, t) else Log.e(tag, message) + } + } + } + + if (!cachedFilteredLogEnabled) return + if (!passesTagFilter(tag)) return + manualTextFilterPattern?.let { if (!it.containsMatchIn(message)) return } + + val ctx = resolveContext(context) ?: return + val fullMessage = if (t != null) "$message :: ${Log.getStackTraceString(t)}" else message + appendLine(ctx, stamped(APP_LOG_FILE), "${level.prefix}/$tag", fullMessage) + } + + private fun appendLine(context: Context, fileName: String, level: String, message: String) { + try { + val now = LocalDateTime.now().format(logTimestampFormatter) + + // Define high-priority files that go to External storage + val isExternal = fileName.startsWith(POST_MORTEM_FILE.substringBeforeLast('.')) + + val dir = getLogsDir(context, isExternal) + File(dir, fileName).appendText("$now $level: $message\n") + } catch (e: Exception) { + // Need to be Log for when baseLog and Timber doesn't work. + Log.e(TAG, "Failed to append to $fileName: ${e.message}", e) + } + } + + // ── Event Watch - window capture ─────────────────────────────── + // + // Brackets exactly the period you care about: screen-lock to + // screen-unlock or app backgrounded. Without android.permission.READ_LOGS granted via + // adb, this only ever sees your own UID's lines (your own Log.* + // calls, including whatever you route through log()/logWarn() + // above) — still useful for confirming your own lifecycle order. + // WITH the permission granted once over adb, it will also surface + // system lines like ActivityManager's "Killing (adj N): + // " messages, which is the signal of the OS killing a process. + + /** + * ToDo: This method can be improved so that it can handle multiple events (calls) that the user can selectively manage, similar to how tags work. + * The best approach would be to use an enum to define what kind of event has been set, replacing the string parameter with the enum (more efficient) + * and adding new enum values as new events are introduced. In this way, it would be possible to select in the UI exactly which specific event + * you want to observe. Examples: ContainerBackground, SteamDownloads, Recording, etc. + */ + @JvmStatic + fun startEventWatch(context: Context, label: String = "undefined-watcher") { + if (!cachedEventWatchEnabled) return + + // Verify READ_LOGS permission at runtime + val hasReadLogs = (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_LOGS) + == PackageManager.PERMISSION_GRANTED) + if (!hasReadLogs) logI(TAG, context) { "READ_LOGS permission not granted, pause watch may not capture system logs" } + + stopEventWatch() + Thread({ + var process: Process? = null + try { + // Wipe the historical buffer so this file only contains lines from + // this pause window onward — not hours of unrelated backlog. + runBlockingLogcatCommand(arrayOf("logcat", "-c")) + + val safeLabel = label.ifBlank { "manual" }.replace(Regex("[^A-Za-z0-9_-]"), "_") + val stamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss", Locale.US)) + val file = File(getLogsDir(context), "event_${safeLabel}_$stamp.log") + appendLine(context, file.name, "I/$TAG", "=== event watch started ($safeLabel) ===") + + val command = mutableListOf("logcat", "-v", "threadtime") + command.addAll(buildLogcatFilterSpecArgs()) + + val started = ProcessBuilder(command) + .redirectErrorStream(true) // merge stderr so a single reader drains both pipes + .start() + process = started + eventWatchProcess = started + closeProcessStdin(started) + + // Schedule a timeout to avoid hanging indefinitely if the user forget to disable this log. + watchTimeoutHandler.postDelayed(stopWatchTask, EVENT_WATCH_TIMEOUT_MS) + + val textPattern = manualTextFilterPattern // snapshot once for the life of this watch + + started.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> + java.io.BufferedWriter( + java.io.OutputStreamWriter(java.io.FileOutputStream(file, true), Charsets.UTF_8) + ).use { writer -> + reader.forEachLine { line -> + if (textPattern == null || textPattern.containsMatchIn(line)) { + writer.write(line) + writer.newLine() + writer.flush() + } + } + } + } + } catch (e: Exception) { + // stopEventWatch() destroys the process to end the read loop above, + // which normally surfaces here as a routine IOException (e.g. + // "Stream closed"). Only log when this wasn't an intentional stop. + if (process == null || eventWatchProcess === process) { + logE(TAG, e, context) { "Failed to start event watch: ${e.message}" } + } + } + }, "LogManager-EventWatch").start() + } + + @JvmStatic + fun stopEventWatch() { + // Cancel the pending timeout task + watchTimeoutHandler.removeCallbacks(stopWatchTask) + + try { + // Only close stdin and destroy the process here. The capture thread in + // startEventWatch() owns and actively reads process.inputStream — + // closing it from this thread too would race with that read. + // Destroying the process closes its end of the pipe instead, which + // unblocks the capture thread's read with a clean EOF. + eventWatchProcess?.let { process -> + closeProcessStdin(process) + process.destroy() + } + eventWatchProcess = null + } catch (e: Exception) { + logE(TAG,e) { "Failed to stop pause watch: ${e.message}" } + } + } + + private fun buildLogcatFilterSpecArgs(): List { + val spec = mutableListOf() + val selectedBaseline = BASELINE_SYSTEM_TAGS.keys.filter { it in cachedSelectedTags }.toSet() + val selectedApp = cachedSelectedTags - BASELINE_SYSTEM_TAGS.keys + + when (cachedTagFilterMode) { + TagFilterMode.ALL -> { + // Wildcard first as the default floor; explicit baseline rules follow + // so they take precedence and elevate those tags to their native level. + // No :S rules anywhere — ALL mode never suppresses anything. + spec.add("*:D") + BASELINE_SYSTEM_TAGS.forEach { (tag, priority) -> spec.add("$tag:$priority") } + } + TagFilterMode.INCLUDE -> { + // Wildcard first to suppress everything; selected tags follow to + // un-suppress themselves by overriding the wildcard. + spec.add("*:S") + selectedBaseline.forEach { tag -> spec.add("$tag:${BASELINE_SYSTEM_TAGS[tag]}") } + selectedApp.forEach { tag -> spec.add("$tag:D") } + } + TagFilterMode.EXCLUDE -> { + // Wildcard first to allow everything; excluded tags follow to suppress + // themselves, non-excluded baseline tags follow to elevate to native level. + spec.add("*:D") + BASELINE_SYSTEM_TAGS.forEach { (tag, priority) -> + if (tag in selectedBaseline) spec.add("$tag:S") + else spec.add("$tag:$priority") + } + selectedApp.forEach { tag -> spec.add("$tag:S") } + } + } + return spec + } + + // ── Exit/killed reasons | crash trace ────────────────────────── + // + // No special permission needed (API 30+). Call once, early, on + // every app start — it tells you, after the fact, exactly what + // ended the previous process: REASON_LOW_MEMORY (real LMK kill), + // REASON_SIGNALED/REASON_OTHER (often an OEM battery manager), + // REASON_USER_REQUESTED, REASON_CRASH, etc. + + @JvmStatic @JvmOverloads + fun logLastExitReasons(context: Context? = null) { + if (!cachedExitReasonLogEnabled && !cachedCrashLogEnabled) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + val ctx = resolveContext(context) ?: return + val maxExitReasons = 5 + + try { + val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val infos: List = am.getHistoricalProcessExitReasons(ctx.packageName, 0, maxExitReasons) + + val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) + val logsDir = getLogsDir(ctx) + val loggedKeys = getPersistedKeys(prefs, PREF_LOGGED_EXIT_KEYS).toMutableSet() + + // Auto-reset keys when a log file no longer exists — covers manual deletion, + // clearLogs() calls, and fresh installs without needing external management. + val originalSize = loggedKeys.size + if (!anyLogExists(logsDir, EXIT_REASONS_FILE)) loggedKeys.removeAll { it.startsWith("exit_") } + if (!anyLogExists(logsDir, CRASH_FILE)) loggedKeys.removeAll { it.startsWith("crash_") } + + var wroteSomething = (loggedKeys.size != originalSize) + + if (infos.isEmpty()) { + // Only write the "no info" placeholder if the file is missing/just cleared. + if (cachedExitReasonLogEnabled && !anyLogExists(logsDir, EXIT_REASONS_FILE)) { + appendLine(ctx, stamped(EXIT_REASONS_FILE), "I/$TAG", "No historical exit info available") + } + if (wroteSomething) persistKeys(prefs, PREF_LOGGED_EXIT_KEYS, loggedKeys) + return + } + + for ((index, info) in infos.withIndex()) { + val key = exitKey(info) + val exitKey = "exit_$key" + val crashKey = "crash_$key" + val timestamp = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(info.timestamp), ZoneId.systemDefault()).format(logTimestampFormatter) + + if (cachedExitReasonLogEnabled && exitKey !in loggedKeys) { + // Separator line with reason number: 0 = newest/last, larger = older + appendLine( + ctx, stamped(EXIT_REASONS_FILE), "I/$TAG", + "\n---- Exit reason #${index} (0=oldest, ${infos.size-1}=new/last) ----" + ) + + appendLine( + ctx, stamped(EXIT_REASONS_FILE), "I/$TAG", + "pid=${info.pid} reason=${info.reason}-[${getExitReasonName(info.reason)}] status=${info.status} " + + "importance=${info.importance} desc=${info.description} timestamp=${timestamp}", + ) + loggedKeys.add(exitKey) + wroteSomething = true + } + if (cachedCrashLogEnabled && crashKey !in loggedKeys) { + val isErrorReport = when (info.reason) { + ApplicationExitInfo.REASON_CRASH, + ApplicationExitInfo.REASON_CRASH_NATIVE, + ApplicationExitInfo.REASON_ANR -> true + else -> false + } + + if (isErrorReport) { + val type = when (info.reason) { + ApplicationExitInfo.REASON_CRASH -> "Java Crash" + ApplicationExitInfo.REASON_CRASH_NATIVE -> "Native Crash" + ApplicationExitInfo.REASON_ANR -> "ANR" + else -> "Critical Error" + } + + try { + info.traceInputStream?.use { input -> + val rawTrace = input.bufferedReader().readText() + val summary = extractTraceExcerpt(rawTrace, info.reason, maxFrames = 20) + appendLine( + ctx, stamped(CRASH_FILE), "I/$TAG", + "\n=== Historical $type Detected ===\n" + + "PID: ${info.pid} | Timestamp: ${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, stamped(CRASH_FILE), "I/$TAG", "Historical $type (No trace available) pid=${info.pid} desc=${info.description}") + } + } catch (e: Exception) { + logE(TAG,e, context) { "Failed to read historical trace: ${e.message}" } + } + } + // Mark as crash-processed to avoid re-scanning non-crash reasons + loggedKeys.add(crashKey) + wroteSomething = true + } + } + + if (wroteSomething) { + persistKeys(prefs, PREF_LOGGED_EXIT_KEYS, loggedKeys) + } + } catch (e: Exception) { + logE(TAG,e, context) { "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 = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss_SSS", Locale.US)) + val fileName = "crashFromThread_$timestamp.log" + val file = File(getLogsDir(context), fileName) + + 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) + logE(TAG, throwable) { "Crash logged to $fileName" } + } catch (e: Exception) { + logE(TAG,e, context) { "Failed to log crash" } + } + } + + /** + * Runs once per app start. Checks whether the previous run ended abnormally + * and writes a concise diagnostic to post_mortem.log. + */ + @JvmStatic + fun runPostMortemIfNeeded(context: Context? = null) { + if (cachedCrashLogEnabled) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + val ctx = resolveContext(context) ?: return + val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) + + // Auto-reset keys when the file no longer exists — covers log deletion, + // fresh installs, and manual file removal without needing clearLogs(). + if (!anyLogExists(getLogsDir(ctx, true), POST_MORTEM_FILE)) { + prefs.edit { remove(PREF_POST_MORTEM_KEYS) } + } + + try { + val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val infos = am.getHistoricalProcessExitReasons(ctx.packageName, 0, 5) + if (infos.isEmpty()) return + + val loggedKeys = getPersistedKeys(prefs, PREF_POST_MORTEM_KEYS).toMutableSet() + var wrote = false + + for (info in infos) { + if (info.reason !in POST_MORTEM_REASONS) continue + val key = "pm_${exitKey(info)}" + if (key in loggedKeys) continue + + appendLine(ctx, stamped(POST_MORTEM_FILE), "I/$TAG", buildPostMortemReport(info, ctx)) + loggedKeys.add(key) + wrote = true + } + if (wrote) persistKeys(prefs, PREF_POST_MORTEM_KEYS, loggedKeys) + } catch (e: Exception) { + logE(TAG,e, context) { "Post-mortem check failed: ${e.message}" } + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun buildPostMortemReport(info: ApplicationExitInfo, context: Context): String { + val timestamp = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(info.timestamp), ZoneId.systemDefault()).format(logTimestampFormatter) + + return buildString { + appendLine("=== POST-MORTEM [${getExitReasonName(info.reason)}] ===") + appendLine("Time : $timestamp") + appendLine("PID : ${info.pid}") + appendLine("Reason : ${info.reason} [${getExitReasonName(info.reason)}]") + appendLine("Status : ${info.status}") + appendLine("Desc : ${info.description ?: "none"}") + try { + val pkg = context.packageManager.getPackageInfo(context.packageName, 0) + appendLine("Build : ${pkg.versionName} (${pkg.longVersionCode})") + } catch (_: Exception) {} + appendLine("Android : ${Build.VERSION.SDK_INT} (${Build.VERSION.RELEASE})") + appendLine("Device : ${Build.MANUFACTURER} ${Build.MODEL}") + try { + info.traceInputStream?.use { stream -> + val excerpt = extractTraceExcerpt( + stream.bufferedReader().readText(), info.reason, maxFrames = 5 + ) + if (excerpt.isNotBlank()) { + appendLine("--- Excerpt ---") + appendLine(excerpt.trim()) + appendLine("--- End Excerpt ---") + } + } + } catch (e: Exception) { + appendLine("Excerpt : unavailable (${e.message})") + } + append("=== END POST-MORTEM ===") + } + } + + // A unique, stable key for one ApplicationExitInfo record. + @RequiresApi(Build.VERSION_CODES.R) + private fun exitKey(info: ApplicationExitInfo): String { + val timestamp = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(info.timestamp), ZoneId.systemDefault()).format(logTimestampFormatter) + return "${info.pid}_$timestamp" + } + + private fun getPersistedKeys(prefs: SharedPreferences, prefKey: String): Set = + prefs.getString(prefKey, null) + ?.split(",")?.filter { it.isNotEmpty() }?.toSet() + ?: emptySet() + + private fun persistKeys(prefs: SharedPreferences, prefKey: String, keys: Set) { + prefs.edit { + // Sort by the numeric timestamp (the last part of the key) instead of alphabetically. + // This ensures we keep the 20 most recent events regardless of whether they + // are "exit_" or "crash_". + val sortedKeys = keys.sortedByDescending { key -> + key.substringAfterLast('_').toLongOrNull() ?: 0L + }.take(20) + + putString(prefKey, sortedKeys.joinToString(",")) + } + } + + // ── Unified trace extraction ────────────────────────────────────────── + // + // Used for both the crash log (maxFrames = 20) and the post-mortem + // (maxFrames = 5). Higher maxFrames → more context; lower → more concise. + + // For avoid useless lines in the crash log. + private val FRAMEWORK_FRAME_PREFIXES = setOf( + "at java.", "at kotlin.", "at android.", "at androidx.", + "at com.android.", "at dalvik.", "at sun.", "at libcore.", + ) + + private fun isFrameworkFrame(line: String): Boolean = + FRAMEWORK_FRAME_PREFIXES.any { line.trimStart().startsWith(it) } + + private fun extractTraceExcerpt(raw: String, reason: Int, maxFrames: Int = 6): String { + val lines = raw.lines() + val concise = maxFrames <= 6 + + return when (reason) { + ApplicationExitInfo.REASON_ANR -> { + val subject = lines.firstOrNull { it.startsWith("Subject:") } + // Waiting Channels shows which kernel call each thread is stuck in. + val channelHeader = lines.firstOrNull { it.contains("Waiting Channels:") } + val channelLines = lines + .dropWhile { !it.contains("Waiting Channels:") } + .drop(1) + .filter { it.contains("sysTid=") } + .take(maxFrames) + (listOfNotNull(subject, channelHeader) + channelLines).joinToString("\n") + } + + ApplicationExitInfo.REASON_CRASH -> { + val out = StringBuilder() + var inException = false + var frameCount = 0 + for (line in lines) { + val t = line.trimStart() + when { + !inException && (t.contains("Exception") || t.contains("Error") + || t.startsWith("Exception in thread")) -> { + inException = true + out.appendLine(line) + } + inException && t.startsWith("at ") && frameCount < maxFrames -> { + // Concise: take every frame to stay within maxFrames. + // Verbose: skip pure framework frames to highlight app code. + if (concise || !isFrameworkFrame(line)) { + out.appendLine(line) + frameCount++ + } + } + inException && t.startsWith("Caused by:") -> { + out.appendLine(line) + frameCount = 0 + } + inException && t.isBlank() -> break + } + } + out.toString().trimEnd().ifEmpty { lines.take(maxFrames).joinToString("\n") } + } + + ApplicationExitInfo.REASON_CRASH_NATIVE -> { + val signal = lines.firstOrNull { it.startsWith("signal ") } + val abort = lines.firstOrNull { it.startsWith("Abort message:") } + val frames = lines.filter { it.trimStart().startsWith("#") }.take(maxFrames) + (listOfNotNull(signal, abort) + frames).joinToString("\n") + } + + // SIGNALED / LOW_MEMORY / EXCESSIVE_RESOURCE_USAGE: + // no trace content is available; the header fields are sufficient. + else -> "" + } + } } diff --git a/app/src/main/runtime/system/ProcessHelper.java b/app/src/main/runtime/system/ProcessHelper.java index 15ef604bc..22f580999 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,28 @@ public abstract class ProcessHelper { } } + public enum BackgroundPauseMode { + ALL("all"), + GAME_ONLY("game_only"); + + private final String prefValue; + + BackgroundPauseMode(String prefValue) { this.prefValue = prefValue; } + public String getPrefValue() { return prefValue; } + + public static BackgroundPauseMode fromPrefValue(String value) { + if (value == null) return GAME_ONLY; + for (BackgroundPauseMode m : values()) { + if (m.prefValue.equals(value)) return m; + } + return GAME_ONLY; + } + } + + private static volatile BackgroundPauseMode backgroundPauseMode = BackgroundPauseMode.GAME_ONLY; + private static volatile int registeredGamePid = -1; + private static final String OOM_TAG = "OomProtectCheck"; + public static native int reapDeadChildrenNow(); public static native void startNativeReaperWindow(int durationMs); @@ -219,16 +243,31 @@ private static boolean isCoreProcess(String normalizedData) { } public static void protectAllWineProcesses() { - ArrayList processes = listRunningWineProcesses(); - for (String process : processes) { - setOomScoreAdj(Integer.parseInt(process), OOM_SCORE_ADJ_PROTECT); - } + ArrayList processes = listRunningWineProcesses(); + boolean eventWatchActive = LogManager.isEventWatchEnabled(); + for (String process : processes) { + if (eventWatchActive) { + String actualAdj = readProcFile("/proc/" + process + "/oom_score_adj"); + LogManager.log(OOM_TAG, "pid=" + process + + " beforeSet=" + (actualAdj != null ? actualAdj.trim() : "unreadable")); + } + + setOomScoreAdj(Integer.parseInt(process), OOM_SCORE_ADJ_PROTECT); + + // Check if the OOM Score is doing something, actually. + if (eventWatchActive) { + String actualAdj = readProcFile("/proc/" + process + "/oom_score_adj"); + LogManager.log(OOM_TAG, + "pid=" + process + " requested=" + OOM_SCORE_ADJ_PROTECT + + " actual=" + (actualAdj != null ? actualAdj.trim() : "unreadable")); + } + } } public static void pauseAllWineProcesses() { File proc = new File("/proc"); ArrayList processes = listRunningWineProcesses(); - if (!processes.isEmpty()) Log.d(TAG, "Pausing session processes: " + processes); + if (!processes.isEmpty()) LogManager.log(TAG, "Pausing session processes: " + processes); for (String process : processes) { int pid = Integer.parseInt(process); @@ -237,15 +276,21 @@ 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; + default: + break; } - - suspendProcess(pid); } } @@ -676,4 +721,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.GAME_ONLY; + } + + 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..ea438a1cb 100644 --- a/app/src/main/runtime/system/SessionKeepAliveService.java +++ b/app/src/main/runtime/system/SessionKeepAliveService.java @@ -1,32 +1,44 @@ package com.winlator.cmod.runtime.system; +import android.app.KeyguardManager; import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; import android.app.Service; +import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; import android.content.pm.ServiceInfo; -import android.net.wifi.WifiManager; import android.os.Build; import android.os.Handler; import android.os.IBinder; import android.os.Looper; import android.os.PowerManager; -import android.util.Log; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; +import androidx.preference.PreferenceManager; import com.winlator.cmod.R; +import com.winlator.cmod.app.shell.UnifiedActivity; +import com.winlator.cmod.feature.stores.steam.utils.PrefManager; import com.winlator.cmod.runtime.display.XServerDisplayActivity; import com.winlator.cmod.runtime.display.environment.XEnvironment; import com.winlator.cmod.runtime.display.xserver.XServer; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import com.winlator.cmod.shared.android.NotificationHelper; + +import timber.log.Timber; + /** * Foreground service that keeps the WinNative process alive while a wine * session is in the background or while a component download/install is @@ -39,59 +51,148 @@ * "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 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"; - private static final String ACTION_SESSION_PAUSE = "com.winlator.cmod.action.SESSION_PAUSE"; - private static final String ACTION_SESSION_RESUME = "com.winlator.cmod.action.SESSION_RESUME"; - private static final String ACTION_DL_START = "com.winlator.cmod.action.SESSION_DL_START"; - private static final String ACTION_DL_STOP = "com.winlator.cmod.action.SESSION_DL_STOP"; - private static final String EXTRA_TAG = "tag"; + + public static final String COMPONENT_STEAM = "Steam"; + public static final String COMPONENT_EPIC = "Epic"; + public static final String COMPONENT_GOG = "GOG"; private static final AtomicBoolean sessionActive = new AtomicBoolean(false); private static final HashSet activeDownloads = new HashSet<>(); private static final AtomicBoolean serviceRunning = new AtomicBoolean(false); + private static volatile boolean serviceStopping = false; private static volatile XEnvironment activeEnvironment; private static volatile XServer activeXServer; private static volatile boolean isContainerPaused = false; + // ── App visibility state ────────────────────────────────────────────── + // isAppInBackground: true when no Activity is STARTED (ProcessLifecycleOwner). + // isScreenLocked: true after ACTION_SCREEN_OFF, cleared by ACTION_USER_PRESENT. + // Both are updated from the main thread; volatile is sufficient for reads. + private static volatile boolean isAppInBackground = false; + private static volatile boolean isScreenLocked = false; + public static volatile boolean exitingFromNotification = false; + private BroadcastReceiver screenStateReceiver; + private static final AtomicBoolean appInPipMode = new AtomicBoolean(false); + 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 + private static long hb_frequency = 0; + + private NotificationHelper notificationHelper; + + // Dedicated thread replaces Handler.postDelayed() — not subject to + // main-looper message-queue deferral in low-power states. + private volatile Thread heartbeatThread; + private volatile AtomicBoolean heartbeatSignal; + private int notificationId = -1; + private static final String NOTIFICATION_ID_NAME = "winnative.keepAlive"; + + // Tracks active components and their notification messages. + // We use a synchronized WeakHashMap for the inner map to prevent Context/Activity leaks. + private static final Map> activeComponents = new ConcurrentHashMap<>(); + + // =================================================================== + // Container / game session lifecycle + // =================================================================== public static void startSession(Context ctx) { if (ctx == null) return; + prefs = PreferenceManager.getDefaultSharedPreferences(ctx.getApplicationContext()); + if (prefs != null) { + hb_frequency = prefs.getInt(PREF_HEARTBEAT_FREQUENCY, 0); + if (hb_frequency > 0) { + if (hb_frequency < 5) + heartbeat_interval_ms = 5 * 1000L; + else + heartbeat_interval_ms = hb_frequency * 1000L; + } + } + sessionActive.set(true); - sendCommand(ctx, ACTION_SESSION_START, null); + isContainerPaused = false; + 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; + LogManager.log(TAG, "onPauseSession", ctx); + if (instance != null) { + instance.acquireWakeLock(); + 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; + 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); + } + }, "XServerTeardown").start(); } public static XEnvironment getActiveEnvironment() { @@ -110,26 +211,70 @@ 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); + public static void setPipMode(boolean inPip) { + appInPipMode.set(inPip); + if (instance != null) { + instance.ensureForeground(); + LogManager.logI(TAG, "setPipMode: " + inPip, instance); + } } - public static void onResumeSession(Context ctx) { - if (ctx == null) return; - sendCommand(ctx, ACTION_SESSION_RESUME, null); + private static boolean isInPictureInPictureMode() { + return appInPipMode.get(); + } + + // =================================================================== + // 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; + + // Use a synchronized WeakHashMap to ensure Contexts aren't pinned in memory + Map owners = activeComponents.get(componentName); + if (owners == null) { + owners = java.util.Collections.synchronizedMap(new java.util.WeakHashMap<>()); + activeComponents.put(componentName, owners); + } + + owners.put(ctx, message != null ? message : ""); + LogManager.log(TAG, "startComponent: " + componentName, ctx); + updateForegroundState(ctx); + } + + public static void stopComponent(Context ctx, String componentName) { + if (ctx == null || componentName == null) return; + Map owners = activeComponents.get(componentName); + if (owners != null) { + if (owners.remove(ctx) != null) { + if (owners.isEmpty()) { + activeComponents.remove(componentName); + } + LogManager.log(TAG, "stopComponent: " + componentName + " (owner: " + ctx + ")", ctx); + updateForegroundState(ctx); + } + } + } + + public static boolean isAppInBackground() { return isAppInBackground; } + public static boolean isDeviceLocked() { return isScreenLocked; } + + public static boolean isAppNotVisible() { + return isAppInBackground || isScreenLocked; } + // =================================================================== + // Background download tracking + // =================================================================== + public static void startDownload(Context ctx, String tag) { if (ctx == null) return; String key = tag == null ? "default" : tag; boolean added; - synchronized (activeDownloads) { - added = activeDownloads.add(key); - } + synchronized (activeDownloads) { added = activeDownloads.add(key); } if (added) { - sendCommand(ctx, ACTION_DL_START, key); + Timber.tag(TAG).d("startDownload: %s", key); + updateForegroundState(ctx); } } @@ -137,120 +282,132 @@ 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; + boolean hasDownload; synchronized (activeDownloads) { - return !activeDownloads.isEmpty(); + hasDownload = !activeDownloads.isEmpty(); } + + return sessionActive.get() || hasDownload || hasActiveComponent() || + (isAppNotVisible() && PrefManager.INSTANCE.getChatStayRunningOnExit()); } - private static void sendCommand(Context ctx, String action, @Nullable String tag) { - Context app = ctx.getApplicationContext(); - Intent intent = new Intent(app, SessionKeepAliveService.class); - intent.setAction(action); - if (tag != null) intent.putExtra(EXTRA_TAG, tag); - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - (ACTION_SESSION_PAUSE.equals(action) || ACTION_SESSION_START.equals(action) || ACTION_DL_START.equals(action))) { - app.startForegroundService(intent); + private static boolean hasActiveComponent() { + for (Map owners : activeComponents.values()) { + if (!owners.isEmpty()) return true; + } + return false; + } + + // Single chokepoint for every caller (session, components, downloads). + // Mutates state first, then either talks to the already-alive instance + // directly (no Intent, no restriction — it's just a method call) or, only + // if the service doesn't exist yet, asks the OS to create it. + private static synchronized void updateForegroundState(Context ctx) { + SessionKeepAliveService svc = instance; + + if (hasReason()) { + if (svc != null && !serviceStopping) { + svc.ensureForeground(); } else { - app.startService(intent); - } - } catch (Exception e) { - // If starting the service fails, try starting it as a foreground service as a fallback. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - app.startForegroundService(intent); + Context app = ctx.getApplicationContext(); + Intent intent = new Intent(app, SessionKeepAliveService.class); + intent.setAction(ACTION_ENSURE_FOREGROUND); + try { + androidx.core.content.ContextCompat.startForegroundService(app, intent); + } catch (Exception e) { + LogManager.logW(TAG, "Failed to start keep-alive service", e, ctx); + } } - Log.w(TAG, "Failed to send command " + action, e); + } else if (svc != null) { + LogManager.log(TAG, "No active reason remains; stopping keep-alive service", ctx); + serviceStopping = true; + serviceRunning.set(false); + svc.stopForegroundCompat(); + svc.stopSelf(); } } + // =================================================================== + // Foreground class logic + // =================================================================== + @Override public void onCreate() { super.onCreate(); - generateNotificationId(); - // Keep the CPU alive to prevent OS from killing the process when the screen is off. + // Initialize the helper using the application context + notificationHelper = new NotificationHelper(getApplicationContext()); + notificationHelper.createNotificationChannel(); // Replace ensureChannel() method. + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); if (pm != null) { - wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WinNative: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; + // Now that fields are ready, publish the instance + instance = this; + + // Seed initial state from current lifecycle rather than assuming foreground. + isAppInBackground = !androidx.lifecycle.ProcessLifecycleOwner.get() + .getLifecycle().getCurrentState() + .isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED); + + androidx.lifecycle.ProcessLifecycleOwner.get() + .getLifecycle() + .addObserver(appLifecycleObserver); + + // Screen-lock detection. ACTION_SCREEN_OFF/USER_PRESENT are protected + // broadcasts — dynamic registration only, no manifest entry needed. + screenStateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (Intent.ACTION_SCREEN_OFF.equals(action)) { + isScreenLocked = true; + LogManager.log(TAG, "Screen turned off / device locked"); + } else if (Intent.ACTION_USER_PRESENT.equals(action)) { + isScreenLocked = false; + LogManager.log(TAG, "Device unlocked (user present)"); + } else if (Intent.ACTION_SCREEN_ON.equals(action)) { + // Screen on but keyguard may still be showing. + KeyguardManager km = (KeyguardManager) getSystemService(KEYGUARD_SERVICE); + isScreenLocked = km != null && km.isKeyguardLocked(); + } + updateForegroundState(context); } - wifiLock = wm.createWifiLock(lockType, "WinNative:WifiKeepAlive"); - } + }; - ensureChannel(); + IntentFilter screenFilter = new IntentFilter(); + screenFilter.addAction(Intent.ACTION_SCREEN_OFF); + screenFilter.addAction(Intent.ACTION_SCREEN_ON); + screenFilter.addAction(Intent.ACTION_USER_PRESENT); + registerReceiver(screenStateReceiver, screenFilter); } @Override public int onStartCommand(Intent intent, int flags, int startId) { String action = intent != null ? intent.getAction() : null; - if (ACTION_SESSION_START.equals(action)) { - sessionActive.set(true); - isContainerPaused = false; - protectionHandler.removeCallbacks(protectionRunnable); - protectionHandler.post(protectionRunnable); - } else if (ACTION_SESSION_PAUSE.equals(action)) { - isContainerPaused = true; - } else if (ACTION_SESSION_RESUME.equals(action)) { - isContainerPaused = false; - } - else if (ACTION_SESSION_STOP.equals(action)) { - sessionActive.set(false); - isContainerPaused = false; - protectionHandler.removeCallbacks(protectionRunnable); - if (activeEnvironment != null) { - final XEnvironment env = activeEnvironment; - activeEnvironment = null; - activeXServer = null; - new Thread(() -> { - try { - env.stopEnvironmentComponents(); - } catch (Exception e) { - Log.e(TAG, "Failed to stop environment components during session stop", e); - } - }, "XServerTeardown").start(); - } - } - - // Ensure wake lock, wifi lock and OOM adj are correct based on current state - if (hasReason()) { - if (wakeLock != null && !wakeLock.isHeld()) wakeLock.acquire(); - if (wifiLock != null && !wifiLock.isHeld()) wifiLock.acquire(); - ProcessHelper.setOomScoreAdj(android.os.Process.myPid(), -1000); - new Thread(() -> { - try { - ProcessHelper.protectAllWineProcesses(); - } catch (Exception e) { - Log.e(TAG, "Failed to run initial OOM protection", e); - } - }, "InitialWineOomProtection").start(); - } else { - if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); - if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); - ProcessHelper.setOomScoreAdj(android.os.Process.myPid(), 0); - } + // Reset the stopping flag as we've received a new start command (or are resuming) + serviceStopping = false; // Always promote to foreground first so Android does not consider // the start a violation (and so the notification reflects current @@ -258,165 +415,400 @@ else if (ACTION_SESSION_STOP.equals(action)) { ensureForeground(); serviceRunning.set(true); + // Handle the Exit button from the notification + if (ACTION_SESSION_STOP.equals(action)) { + exitingFromNotification = true; + boolean chatStayAlive = PrefManager.INSTANCE.getChatStayRunningOnExit(); + + // If a game is running, and we want to keep chat alive, only stop the session. + // updateForegroundState() will be called inside stopSession, + // and it will update the notification to the "Chat" state. + if (sessionActive.get() && chatStayAlive) { + stopSession(this); + + // Move the blocking cleanup to a background thread to prevent ANRs. + // We use a small delay to allow stopSession's environment teardown to start. + new Thread(() -> { + try { + Thread.sleep(1000L); + cleanUpSession(getApplicationContext(), "Exit button pressed"); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } catch (Throwable t) { + LogManager.logW(TAG, "Background session cleanup failed", t, getApplicationContext()); + } + }, "SessionExitCleanup").start(); + } else { + // Otherwise, perform a full app shutdown. + closeApp(this); + } + return START_NOT_STICKY; + } + if (!hasReason()) { - Log.d(TAG, "No active reason; stopping keep-alive service"); + Timber.tag(TAG).d("onStartCommand found no active reason; stopping immediately"); + serviceStopping = true; 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 = isAppNotVisible() && (containerActive || PrefManager.INSTANCE.getChatStayRunningOnExit()); // Disabled because container "Exit" causes too much issues, ANR crash for example. + boolean showExit = isAppNotVisible() && (!containerActive && PrefManager.INSTANCE.getChatStayRunningOnExit()); + + // Determine target activity: Game screen if active, else Main menu + 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); - } - } - - 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"; - } - - 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(); + LogManager.logW(TAG, "Failed to stopForeground", e, this); + } } + @Override + public void onTimeout(int startId, int fstype) { + /* + * Note: This callback is unreachable while targetSdk is 28 (requires API 34+). + * If the SDK is bumped in the future, we must route through stopSession to ensure + * that environment components are torn down and wine processes are not leaked, + * rather than just stopping the service while they are in a protected/frozen state. + */ + super.onTimeout(startId, fstype); + Timber.tag(TAG).w("Service reached 6-hour limit for dataSync. Stopping gracefully."); + + // Stop the service and cleanup + stopSession(this); + 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() { + // Null the instance immediately to close the window for other threads + instance = null; + serviceStopping = false; + serviceRunning.set(false); + +// if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); +// if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); + stopHeartbeat(); + releaseWakeLock(); + + androidx.lifecycle.ProcessLifecycleOwner.get() + .getLifecycle() + .removeObserver(appLifecycleObserver); + + if (screenStateReceiver != null) { + try { unregisterReceiver(screenStateReceiver); } catch (Exception ignored) {} + screenStateReceiver = null; + } + + if (notificationHelper != null) { + notificationHelper.cancel(notificationId); + } + 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 (heartbeatThread != null || !prefs.getBoolean("enable_background_wakelock", false) || hb_frequency <= 0) return; + + synchronized (this) { + if (heartbeatThread != null) return; + + final AtomicBoolean signal = new AtomicBoolean(true); + heartbeatSignal = signal; + + Thread t = new Thread(() -> { + try { + while (signal.get() && sessionActive.get() && isContainerPaused) { + Thread.sleep(heartbeat_interval_ms); + + // Verify state again after waking up from sleep + if (!signal.get() || !isContainerPaused) break; + + LogManager.log(TAG, "Heartbeat: Keeping container alive...", getApplicationContext()); + runOomSweepInternal(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }, "SessionHeartbeat"); + t.setDaemon(true); + heartbeatThread = t; + t.start(); + } + } + + private void stopHeartbeat() { + synchronized (this) { + // Signal the thread to stop its loop + if (heartbeatSignal != null) { + heartbeatSignal.set(false); + heartbeatSignal = null; + } + + // Interrupt the thread if it's currently sleeping or blocked in OOM sweep + if (heartbeatThread != null) { + heartbeatThread.interrupt(); + heartbeatThread = null; + LogManager.log(TAG, "Heartbeat stopped", this); + } + } + } + + private void runOomSweepInternal() { + try { + ProcessHelper.protectAllWineProcesses(); + } catch (Exception e) { + LogManager.logE(TAG, "OOM protection sweep failed", e, this); + } + } + + @NonNull + private static String getNotificationContent() { + // Capture a local reference to avoid NPE if instance is nulled concurrently + SessionKeepAliveService svc = instance; + if (svc == null) return "WinNative is running in the background"; + + // 1. HIGHEST PRIORITY: The game/container + if (sessionActive.get()) { + return (isContainerPaused + && (isDeviceLocked() || !isInPictureInPictureMode())) + ? svc.getString(R.string.fg_keep_alive_notification_content_container_paused) + : svc.getString(R.string.fg_keep_alive_notification_content_container_running); + } + + // 2. MEDIUM PRIORITY: Downloads synchronized (activeDownloads) { - activeDownloads.clear(); + if (!activeDownloads.isEmpty()) return svc.getString(R.string.fg_keep_alive_notification_content_downloading_installing); + } + + // 3. MEDIUM PRIORITY: Steam friends (if enabled) + if (PrefManager.INSTANCE.getChatStayRunningOnExit() && isAppNotVisible()) + return svc.getString(R.string.fg_keep_alive_notification_content_steam_chat_running); + + // 4. LOW PRIORITY: Active store services + // Filter out keys that might have been cleared by Garbage Collection + List names = new ArrayList<>(); + for (Map.Entry> entry : activeComponents.entrySet()) { + if (!entry.getValue().isEmpty()) { + names.add(entry.getKey()); + } } + if (!activeComponents.isEmpty()) { + // Define the priority order + List priority = Arrays.asList(COMPONENT_STEAM, COMPONENT_EPIC, COMPONENT_GOG); + names.sort((a, b) -> { + int idxA = priority.indexOf(a); + int idxB = priority.indexOf(b); + if (idxA != -1 && idxB != -1) return Integer.compare(idxA, idxB); + if (idxA != -1) return -1; + if (idxB != -1) return 1; + return a.compareTo(b); + }); + + String joinedNames = names.get(0); + int size = names.size(); + + switch (size) { + case 0: + break; + case 1: + joinedNames = names.get(0); + break; + case 2: + joinedNames = names.get(0) + ' ' + instance.getString(R.string.general_and) + ' ' + names.get(1); + break; + default: + // Future-proof: "A, B, and C" + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < size; i++) { + sb.append(names.get(i)); + if (i < size - 2) { + sb.append(", "); + } else if (i == size - 2) { + sb.append(", " + instance.getString(R.string.general_and) + ' '); + } + } + joinedNames = sb.toString(); + } + + String suffix = (size == 1) ? svc.getString(R.string.fg_keep_alive_notification_content_store_service_active) : svc.getString(R.string.fg_keep_alive_notification_content_store_services_active); + return joinedNames + suffix; + } + return "WinNative is running in the background"; + } + + 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; + synchronized (activeDownloads) { activeDownloads.clear(); } + activeComponents.clear(); + } + + /** + * Performs a deep cleanup of native processes and terminates the app PID. + * This is the shared logic between swiping away and clicking "Exit". + */ + private static void performDefensiveCleanupAndExit(Context ctx) { // Give the activity's own onDestroy → performForcedSessionCleanup a // chance to run first; then defensively clean any wine processes that - // might still be alive, and exit the process so swipe behaves like the + // might still be alive, and exit the process so swipe/exit button behaves like the // pre-existing "swipe-away closes everything" flow. new Thread(() -> { try { Thread.sleep(1500L); - if (com.winlator.cmod.feature.stores.steam.service.SteamService - .Companion.isBionicHandoffActive()) { - try { - boolean kicked = com.winlator.cmod.feature.stores.steam.service.SteamService - .Companion.bionicHandoffReleaseAndKickPlayingSessionBlocking(true, 2500L); - Log.i(TAG, "Task removal Steam cleanup: kickedPlayingSession=" + kicked); - } catch (Throwable t) { - Log.w(TAG, "Task removal Steam cleanup failed", t); - } - } - ProcessHelper.terminateSessionProcessesAndWait(1500, true); - ProcessHelper.drainDeadChildren("session keep-alive task removed"); + cleanUpSession(ctx, "session keep-alive shutdown"); } catch (Throwable t) { - Log.w(TAG, "Defensive wine cleanup on task removal failed", t); + LogManager.logW(TAG, "Defensive cleanup failed", t, ctx); } + new Handler(Looper.getMainLooper()).post(() -> { - stopForegroundCompat(); - stopSelf(); serviceRunning.set(false); - // Match the previous swipe behaviour: actually exit the process. - // We do this in a slight delay to ensure stopSelf() has processed. - new Handler(Looper.getMainLooper()).postDelayed(() -> { - android.os.Process.killProcess(android.os.Process.myPid()); - }, 500L); + if (instance != null) { + instance.stopForegroundCompat(); + instance.stopSelf(); + } + // Final kill + new Handler(Looper.getMainLooper()).postDelayed( + () -> android.os.Process.killProcess(android.os.Process.myPid()), 500L); }); - }, "SessionKeepAliveCleanup").start(); + }, "SessionCleanupAndExit").start(); } - @Override - public void onDestroy() { - protectionHandler.removeCallbacks(protectionRunnable); - if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); - if (wifiLock != null && wifiLock.isHeld()) wifiLock.release(); - serviceRunning.set(false); - super.onDestroy(); + private static void cleanUpSession(Context ctx, String reason) { + if (com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isBionicHandoffActive()) { + try { + boolean kicked = com.winlator.cmod.feature.stores.steam.service.SteamService + .Companion.bionicHandoffReleaseAndKickPlayingSessionBlocking(true, 2500L); + LogManager.logI(TAG, "Task removal/Exit button - Steam cleanup: kickedPlayingSession=" + kicked, ctx); + } catch (Throwable t) { + LogManager.logW(TAG, "Task removal/Exit button - Steam cleanup failed", t, ctx); + } + } + ProcessHelper.terminateSessionProcessesAndWait(1500, true); + ProcessHelper.drainDeadChildren(reason); } - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; + public static void stopAll(Context ctx) { + stopSession(ctx); + resetLocalState(); + + // Stop Steam specifically + com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.stop(); + + // Finally stop the master service + SessionKeepAliveService svc = instance; + if (svc != null) { + svc.stopForegroundCompat(); + svc.stopSelf(); + } } - private void generateNotificationId() { - // Generate a unique ID based on the package name to avoid conflicts with other forks/flavors. - String contextKey = getPackageName() + ".winnative.keepAlive"; - notificationId = contextKey.hashCode() & 0x7FFFFFFF; // Avoid negative IDs + // Stop everything and kill the app process. + public static void closeApp(Context ctx) { + stopAll(ctx); + performDefensiveCleanupAndExit(ctx); } } diff --git a/app/src/main/runtime/system/SessionLogWriter.java b/app/src/main/runtime/system/SessionLogWriter.java index d8fe4c325..c968126fb 100644 --- a/app/src/main/runtime/system/SessionLogWriter.java +++ b/app/src/main/runtime/system/SessionLogWriter.java @@ -39,7 +39,7 @@ public static SessionLogWriter create( boolean fexActive) { SessionLogWriter writer = new SessionLogWriter(); try { - File logsDir = LogManager.getLogsDir(context); + File logsDir = LogManager.getLogsDir(context, false); String exe = sanitize(executable); String stamp = DateFormat.format("yyyy-MM-dd_HH-mm-ss", new Date()).toString(); diff --git a/app/src/main/shared/android/NotificationHelper.kt b/app/src/main/shared/android/NotificationHelper.kt index 25bf7c9b9..4de5d3823 100644 --- a/app/src/main/shared/android/NotificationHelper.kt +++ b/app/src/main/shared/android/NotificationHelper.kt @@ -10,10 +10,8 @@ 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,188 +21,200 @@ 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" + const val EXTRA_OPEN_CHAT_FRIEND_ID = BuildConfig.APPLICATION_ID + ".OPEN_CHAT_FRIEND_ID" const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".EXIT" - const val EXTRA_OPEN_CHAT_FRIEND_ID = BuildConfig.APPLICATION_ID + ".OPEN_CHAT_FRIEND_ID" } 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) + // Steam chat channel + createNotificationChannel( + context.applicationContext, + CHAT_CHANNEL_ID, + CHAT_CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH, + "Incoming Steam friend messages", + true + ) + } - val chatChannel = - NotificationChannel( - CHAT_CHANNEL_ID, - CHAT_CHANNEL_NAME, - NotificationManager.IMPORTANCE_HIGH, - ).apply { - description = "Incoming Steam friend messages" - setShowBadge(true) - } + // Sends or updates a notification. + fun notify( + id: Int, + content: String, + title: String = context.getString(R.string.common_ui_app_name), + serviceClass: Class<*>? = null, + exitAction: String? = null + ) { + val notification = createForegroundNotification(content, title, serviceClass, exitAction) + notificationManager.notify(id, notification) + } - notificationManager.createNotificationChannel(chatChannel) + // Overload to notify using a pre-built Notification object. + fun notify(id: Int, notification: Notification) { + notificationManager.notify(id, notification) + } - val backgroundChannel = - NotificationChannel( - CHAT_BG_CHANNEL_ID, - CHAT_BG_CHANNEL_NAME, - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = "Shown while Steam chat keeps running after you exit" - setShowBadge(false) - setSound(null, null) - enableVibration(false) - } + fun cancel(id: Int) { + notificationManager.cancel(id) + } - notificationManager.createNotificationChannel(backgroundChannel) + 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 } - private fun chatNotificationId(friendId: Long): Int = 2_000_000 + ((friendId.hashCode() and 0x7FFFFFFF) % 1_000_000) - - fun notifyChatMessage(friendId: Long, sender: String, message: String) { - val intent = - Intent(context, UnifiedActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra(EXTRA_OPEN_CHAT_FRIEND_ID, friendId) - } - val pendingIntent = - PendingIntent.getActivity( - context, - friendId.hashCode(), - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - val notification = - NotificationCompat - .Builder(context, CHAT_CHANNEL_ID) - .setContentTitle(sender) - .setContentText(message) - .setSmallIcon(R.drawable.ic_notification) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setAutoCancel(true) - .setStyle(NotificationCompat.BigTextStyle().bigText(message)) - .setContentIntent(pendingIntent) - .build() - notificationManager.notify(chatNotificationId(friendId), notification) + 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 cancelChatNotification(friendId: Long) { - notificationManager.cancel(chatNotificationId(friendId)) - } + return builder.build() + } - fun notify(content: String) { - val notification = createForegroundNotification(content) - notificationManager.notify(NOTIFICATION_ID, notification) - } + /** + * 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 + } - fun cancel() { - notificationManager.cancel(NOTIFICATION_ID) + nm.createNotificationChannel(channel) } + } - fun cancelBackgroundRunning() { - notificationManager.cancel(BACKGROUND_RUNNING_NOTIFICATION_ID) - } + /** + * Overload of createNotificationChannel() + * without description. + */ + fun createNotificationChannel( + context: Context, + channelId: String?, + name: String?, + importance: Int + ) { + createNotificationChannel(context, channelId, name, importance, "", false) + } - 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 - } + /** + * Overload of createNotificationChannel() + * using default values. + */ + fun createNotificationChannel() { + createNotificationChannel( + context, + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW, + "Allows to display WinNative foreground notifications", + false + ) + } - 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() + /** + * 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 } - 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") + 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..3cea2bc0c --- /dev/null +++ b/gradle/collectLogTags.gradle @@ -0,0 +1,112 @@ +// 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 srcDirsProvider = providers.gradleProperty('collectLogTags.srcDirs') +def overwriteProvider = providers.gradleProperty('collectLogTags.overwrite') + +def defaultCandidates = [ + 'src/main/app', + 'src/main/feature', + 'src/main/sharedmemory', + 'src/main/runtime', + 'src/main/shared' +] + +def javaDir = file('src/main/java') +def outputFile = file('build/generated/source/logtags/com/winlator/cmod/runtime/system/GeneratedLogTags.kt') +def manualOutputFile = file('src/main/java/com/winlator/cmod/runtime/system/GeneratedLogTags.kt') + +def srcDirs = srcDirsProvider.isPresent() ? + srcDirsProvider.get().split(',').collect { file(it.trim()) } : + defaultCandidates.collect { file(it) } + +// Remove any non-existent directories and explicitly exclude the java src dir to avoid duplicates +srcDirs = srcDirs.findAll { it.exists() && !(javaDir.exists() && it.canonicalFile == javaDir.canonicalFile) } + +tasks.register('collectLogTags') { + group = 'build' + description = 'Collects all TAG variables and literal LogManager calls into a generated class.' + + srcDirs.each { dir -> + inputs.files(fileTree(dir).include('**/*.kt', '**/*.java')) + .withPropertyName("srcDir_${dir.name}_${dir.path.hashCode()}") + .withPathSensitivity(PathSensitivity.RELATIVE) + .optional() + } + + // Manual file as optional input to detect changes + if (manualOutputFile.exists()) { + inputs.file(manualOutputFile).optional().withPropertyName("manualFile") + } + + // Task ALWAYS produces the build-dir output to satisfy compiler requirements + outputs.file outputFile + + doLast { + def overwrite = overwriteProvider.getOrElse("false").toBoolean() + + // Fix: Mirror manual file if it exists and we aren't overwriting + if (manualOutputFile.exists() && !overwrite) { + println "collectLogTags: Manual GeneratedLogTags.kt exists. Mirroring to generated path." + outputFile.parentFile.mkdirs() + outputFile.text = manualOutputFile.text + return + } + + def tags = new TreeSet() + def kotlinTagDecl = ~/((?:private\s+)?(?:const\s+)?val\s+TAG\s*=\s*")([^"]+)/ + def javaTagDecl = ~/private\s+static\s+final\s+String\s+TAG\s*=\s*"([^"]+)"\s*;/ + def literalCallSite = ~/LogManager\.(?:log|logI|logW|logE)\(\s*"([^"]+)"/ + // CLEVER REGEX: + // \b : Start at a word boundary + // (?![A-Z0-9_]*PREF_) : Look ahead and fail if the variable name contains "PREF_" + // [A-Z0-9_]* : Match optional uppercase/numeric/underscore prefix + // TAG : Match the literal word "TAG" + // \b : Ensure "TAG" is the end of the word (excludes TAGS, TAG_FILTER) + def genericTagDecl = ~/\b(?![A-Z0-9_]*PREF_)([A-Z0-9_]*TAG)\b\s*=\s*"([^"]+)"/ + + inputs.files.each { f -> + if (!f.isFile() || f == manualOutputFile) return + + // Safety: Skip anything physically or logically inside src/main/java junctions + if (f.path.contains("src/main/java") || f.path.contains("src\\main\\java")) return + try { + if (javaDir.exists() && f.canonicalPath.startsWith(javaDir.canonicalPath)) return + } catch (ignored) {} + + def text = f.text + if (!text.contains('LogManager.')) return + + // Efficiently collect ALL unique matches in the file + text.findAll(literalCallSite) { match -> tags.add(match[1]) } + text.findAll(genericTagDecl) { match -> tags.add(match[2]) } + + // Filename fallback if no tags found but LogManager is used + if (tags.isEmpty()) { + tags.add(f.name.replaceFirst(/\.[^.]+$/, '')) + } + } + + def generatedContent = """package com.winlator.cmod.runtime.system + +/** Auto-generated by the collectLogTags Gradle task. Do not edit by hand. */ +object GeneratedLogTags { + val TAGS: Set = setOf( +${tags.collect { " \"$it\"" }.join(",\n")} + ) +} +""" + + outputFile.parentFile.mkdirs() + outputFile.text = generatedContent + + if (overwrite) { + manualOutputFile.parentFile.mkdirs() + manualOutputFile.text = generatedContent + println "collectLogTags: Overwrote manual file at ${manualOutputFile}" + } + + println "collectLogTags: wrote ${outputFile} with ${tags.size()} tags." + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 614327ea6..445c613ce 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.9.0" # New = compilation errors / gradle plugin update requirement. [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" }