diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 74c914f2ed..5a6c15450a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,6 +35,10 @@ android { // https://developer.android.com/ndk/downloads ndkVersion = "22.1.7171670" + lint { + disable += setOf("ExtraTranslation", "ExpiredTargetSdkVersion") + } + signingConfigs { create("pluvia") { if (keystoreProperties != null) { @@ -115,18 +119,18 @@ android { signingConfig = signingConfigs.getByName("debug") } release { - isMinifyEnabled = true - isShrinkResources = true + isMinifyEnabled = false + isShrinkResources = false signingConfig = signingConfigs.getByName("debug") } create("release-signed") { - isMinifyEnabled = true - isShrinkResources = true + isMinifyEnabled = false + isShrinkResources = false signingConfig = signingConfigs.getByName("pluvia") } create("release-gold") { - isMinifyEnabled = true - isShrinkResources = true + isMinifyEnabled = false + isShrinkResources = false signingConfig = signingConfigs.getByName("pluvia") applicationIdSuffix = ".gold" buildConfigField("boolean", "GOLD", "true") @@ -219,7 +223,7 @@ dependencies { implementation("androidx.browser:browser:1.8.0") // JavaSteam - val localBuild = false // Change to 'true' needed when building JavaSteam manually + val localBuild = true // Change to 'true' needed when building JavaSteam manually if (localBuild) { implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-18-SNAPSHOT.jar")) implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-18-SNAPSHOT.jar")) diff --git a/app/src/main/assets/inputcontrols/profiles/controls-3.icp b/app/src/main/assets/inputcontrols/profiles/controls-3.icp index 86479db9a0..7cc7483be6 100644 --- a/app/src/main/assets/inputcontrols/profiles/controls-3.icp +++ b/app/src/main/assets/inputcontrols/profiles/controls-3.icp @@ -1 +1 @@ -{"id":3,"name":"Virtual Gamepad","cursorSpeed":1,"elements":[{"type":"D_PAD","shape":"CIRCLE","bindings":["GAMEPAD_DPAD_UP","GAMEPAD_DPAD_RIGHT","GAMEPAD_DPAD_DOWN","GAMEPAD_DPAD_LEFT"],"scale":0.85,"x":0.10784313827753067,"y":0.4,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_X","NONE","NONE","NONE"],"scale":1,"x":0.8133170008659363,"y":0.4,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_Y","NONE","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.2666666746,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_A","NONE","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.5333333254,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_B","NONE","NONE","NONE"],"scale":1,"x":0.9309640526771545,"y":0.4,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_R2","NONE","NONE","NONE"],"scale":2,"x":0.93,"y":0.07,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_R1","NONE","NONE","NONE"],"scale":1,"x":0.97,"y":0.2222222388,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_L1","NONE","NONE","NONE"],"scale":1,"x":0.03,"y":0.2222222388,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_L2","NONE","NONE","NONE"],"scale":2,"x":0.07,"y":0.07,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"ROUND_RECT","bindings":["GAMEPAD_BUTTON_START","NONE","NONE","NONE"],"scale":0.85,"x":0.538807213306427,"y":0.9111111164093018,"toggleSwitch":false,"text":"","iconId":15},{"type":"BUTTON","shape":"ROUND_RECT","bindings":["GAMEPAD_BUTTON_SELECT","NONE","NONE","NONE"],"scale":0.85,"x":0.46078431606292725,"y":0.9111111164093018,"toggleSwitch":false,"text":"","iconId":16},{"type":"STICK","shape":"CIRCLE","bindings":["GAMEPAD_LEFT_THUMB_UP","GAMEPAD_LEFT_THUMB_RIGHT","GAMEPAD_LEFT_THUMB_DOWN","GAMEPAD_LEFT_THUMB_LEFT"],"scale":1,"x":0.21568627655506134,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0},{"type":"STICK","shape":"CIRCLE","bindings":["GAMEPAD_RIGHT_THUMB_UP","GAMEPAD_RIGHT_THUMB_RIGHT","GAMEPAD_RIGHT_THUMB_DOWN","GAMEPAD_RIGHT_THUMB_LEFT"],"scale":1,"x":0.7843137383460999,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_L3","NONE","NONE","NONE"],"scale":0.85,"x":0.05,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_R3","NONE","NONE","NONE"],"scale":0.85,"x":0.95,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0}]} +{"id":3,"name":"Virtual Gamepad","cursorSpeed":1,"elements":[{"type":"D_PAD","shape":"CIRCLE","bindings":["GAMEPAD_DPAD_UP","GAMEPAD_DPAD_RIGHT","GAMEPAD_DPAD_DOWN","GAMEPAD_DPAD_LEFT"],"scale":0.85,"x":0.10784313827753067,"y":0.4,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_X","GAMEPAD_BUTTON_X","NONE","NONE"],"scale":1,"x":0.8133170008659363,"y":0.4,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_Y","GAMEPAD_BUTTON_Y","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.2666666746,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_A","GAMEPAD_BUTTON_A","NONE","NONE"],"scale":1,"x":0.8721405267715454,"y":0.5333333254,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_B","GAMEPAD_BUTTON_B","NONE","NONE"],"scale":1,"x":0.9309640526771545,"y":0.4,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_R2","GAMEPAD_BUTTON_R2","NONE","NONE"],"scale":2,"x":0.93,"y":0.07,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_R1","GAMEPAD_BUTTON_R1","NONE","NONE"],"scale":1,"x":0.97,"y":0.2222222388,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_L1","GAMEPAD_BUTTON_L1","NONE","NONE"],"scale":1,"x":0.03,"y":0.2222222388,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"RECT","bindings":["GAMEPAD_BUTTON_L2","GAMEPAD_BUTTON_L2","NONE","NONE"],"scale":2,"x":0.07,"y":0.07,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"ROUND_RECT","bindings":["GAMEPAD_BUTTON_START","GAMEPAD_BUTTON_START","NONE","NONE"],"scale":0.85,"x":0.538807213306427,"y":0.9111111164093018,"toggleSwitch":false,"text":"","iconId":15},{"type":"BUTTON","shape":"ROUND_RECT","bindings":["GAMEPAD_BUTTON_SELECT","GAMEPAD_BUTTON_SELECT","NONE","NONE"],"scale":0.85,"x":0.46078431606292725,"y":0.9111111164093018,"toggleSwitch":false,"text":"","iconId":16},{"type":"STICK","shape":"CIRCLE","bindings":["GAMEPAD_LEFT_THUMB_UP","GAMEPAD_LEFT_THUMB_RIGHT","GAMEPAD_LEFT_THUMB_DOWN","GAMEPAD_LEFT_THUMB_LEFT"],"scale":1,"x":0.21568627655506134,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0},{"type":"STICK","shape":"CIRCLE","bindings":["GAMEPAD_RIGHT_THUMB_UP","GAMEPAD_RIGHT_THUMB_RIGHT","GAMEPAD_RIGHT_THUMB_DOWN","GAMEPAD_RIGHT_THUMB_LEFT"],"scale":1,"x":0.7843137383460999,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_L3","GAMEPAD_BUTTON_L3","NONE","NONE"],"scale":0.85,"x":0.05,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0},{"type":"BUTTON","shape":"CIRCLE","bindings":["GAMEPAD_BUTTON_R3","GAMEPAD_BUTTON_R3","NONE","NONE"],"scale":0.85,"x":0.95,"y":0.7333333492279053,"toggleSwitch":false,"text":"","iconId":0}]} \ No newline at end of file diff --git a/app/src/main/java/app/gamenative/data/DownloadInfo.kt b/app/src/main/java/app/gamenative/data/DownloadInfo.kt index 2bfabf8935..eed9198ba5 100644 --- a/app/src/main/java/app/gamenative/data/DownloadInfo.kt +++ b/app/src/main/java/app/gamenative/data/DownloadInfo.kt @@ -33,6 +33,7 @@ data class DownloadInfo( private var hasEmaSpeed: Boolean = false private var isActive: Boolean = true private val statusMessage = MutableStateFlow(null) + private val downloadLog = MutableStateFlow>(emptyList()) private val postInstallSyncing = MutableStateFlow(false) fun cancel() { @@ -142,6 +143,19 @@ data class DownloadInfo( fun getStatusMessageFlow(): StateFlow = statusMessage + fun appendLog(message: String) { + val timestamp = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date()) + downloadLog.value = downloadLog.value + "[$timestamp] $message" + // Keep last 500 entries to avoid memory issues + if (downloadLog.value.size > 500) { + downloadLog.value = downloadLog.value.takeLast(500) + } + // Also update the single status message for backward compatibility + statusMessage.value = message + } + + fun getDownloadLogFlow(): StateFlow> = downloadLog + fun setPostInstallSyncing(syncing: Boolean) { postInstallSyncing.value = syncing } diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index e4d1b402d8..f2c7314a2e 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -49,6 +49,7 @@ import app.gamenative.enums.SaveLocation import app.gamenative.enums.SyncResult import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent +import app.gamenative.utils.DownloadLogger import app.gamenative.utils.CaseInsensitiveFileSystem import app.gamenative.utils.ContainerUtils import app.gamenative.utils.FileUtils @@ -116,8 +117,6 @@ import `in`.dragonbra.javasteam.types.FileData import `in`.dragonbra.javasteam.types.KeyValue import `in`.dragonbra.javasteam.types.PublishedFileID import `in`.dragonbra.javasteam.types.SteamID -import `in`.dragonbra.javasteam.util.log.LogListener -import `in`.dragonbra.javasteam.util.log.LogManager import java.io.Closeable import java.io.File import java.io.IOException @@ -184,7 +183,7 @@ import java.nio.ByteOrder class SteamService : Service(), IChallengeUrlChanged { // To view log messages in android logcat properly - private val logger = object : LogListener { + private val logger = object : `in`.dragonbra.javasteam.util.log.LogListener { override fun onLog(clazz: Class<*>, message: String?, throwable: Throwable?) { val logMessage = message ?: "No message given" Timber.i(throwable, "[${clazz.simpleName}] -> $logMessage") @@ -1768,6 +1767,35 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.i("maxDownloads: $maxDownloads") Timber.i("maxDecompress: $maxDecompress") + // Register a log listener to capture JavaSteam internal logs + val logListenerActive = java.util.concurrent.atomic.AtomicBoolean(true) + val logListener = object : `in`.dragonbra.javasteam.util.log.LogListener { + override fun onLog(clazz: Class<*>, message: String?, throwable: Throwable?) { + if (!logListenerActive.get()) return + val prefix = clazz.simpleName + message?.let { DownloadLogger.append(appId, "[$prefix] $it") } + throwable?.let { DownloadLogger.append(appId, "[$prefix] ${it.message ?: it.javaClass.simpleName}") } + } + override fun onError(clazz: Class<*>, message: String?, throwable: Throwable?) { + if (!logListenerActive.get()) return + val prefix = clazz.simpleName + message?.let { DownloadLogger.append(appId, "[$prefix] ERROR: $it") } + throwable?.let { DownloadLogger.append(appId, "[$prefix] ERROR: ${it.message ?: it.javaClass.simpleName}") } + } + } + + // Clear previous download log BEFORE registering listener (avoid race) + DownloadLogger.clear(appId) + + `in`.dragonbra.javasteam.util.log.LogManager.addListener(logListener) + + // Guaranteed cleanup: remove listener when the download coroutine completes + val downloadCleanup = { + logListenerActive.set(false) + `in`.dragonbra.javasteam.util.log.LogManager.removeListener(logListener) + } + coroutineContext[Job]?.invokeOnCompletion { downloadCleanup() } + // Create DepotDownloader instance val depotDownloader = DepotDownloader( instance!!.steamClient!!, @@ -2144,22 +2172,26 @@ class SteamService : Service(), IChallengeUrlChanged { override fun onDownloadStarted(item: DownloadItem) { Timber.i("Item ${item.appId} download started") + DownloadLogger.append(downloadInfo.gameId, "Download started for app ${item.appId}") } override fun onDownloadCompleted(item: DownloadItem) { Timber.i("Item ${item.appId} download completed") + DownloadLogger.append(downloadInfo.gameId, "Download completed") + DownloadLogger.flush(downloadInfo.gameId) } override fun onDownloadFailed(item: DownloadItem, error: Throwable) { Timber.e(error, "Item ${item.appId} failed to download") + DownloadLogger.append(downloadInfo.gameId, "FAILED: ${error.message ?: error.javaClass.simpleName}") + DownloadLogger.flush(downloadInfo.gameId) downloadInfo.failedToDownload() - // Remove the downloading app info + // Remove the downloading app info from DB runBlocking { instance?.downloadingAppInfoDao?.deleteApp(downloadInfo.gameId) } - removeDownloadJob(downloadInfo.gameId) instance?.let { service -> SnackbarManager.show(service.getString(R.string.download_failed_try_again)) } @@ -2167,7 +2199,7 @@ class SteamService : Service(), IChallengeUrlChanged { override fun onStatusUpdate(message: String) { Timber.d("Download status: $message") - downloadInfo.updateStatusMessage(message) + DownloadLogger.append(downloadInfo.gameId, message) } override fun onChunkCompleted( @@ -3277,7 +3309,7 @@ class SteamService : Service(), IChallengeUrlChanged { connectivityManager.registerNetworkCallback(networkRequest, networkCallback) // To view log messages in android logcat properly - LogManager.addListener(logger) + `in`.dragonbra.javasteam.util.log.LogManager.addListener(logger) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -3477,7 +3509,7 @@ class SteamService : Service(), IChallengeUrlChanged { PluviaApp.events.off(onEndProcess) PluviaApp.events.clearAllListenersOf>() - LogManager.removeListener(logger) + `in`.dragonbra.javasteam.util.log.LogManager.removeListener(logger) } private fun reconnect() { diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 937d077b40..dc7a516e01 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -61,6 +61,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -95,14 +96,17 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.PaddingValues import app.gamenative.NetworkMonitor import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.data.LibraryItem import app.gamenative.service.SteamService +import app.gamenative.utils.DownloadLogger import app.gamenative.ui.component.GamepadAction import app.gamenative.ui.component.GamepadActionBar import app.gamenative.ui.component.GamepadButton @@ -1094,6 +1098,91 @@ internal fun AppScreenContent( } } } + + // Download log — backed by persistent DownloadLogger + val downloadLogFlow = remember(displayInfo.gameId) { + DownloadLogger.getLogFlow(displayInfo.gameId) + } + val downloadLog by downloadLogFlow.collectAsState(initial = emptyList()) + if (downloadLog.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.download_log_title), + style = MaterialTheme.typography.titleSmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // Copy button + TextButton( + onClick = { + val text = DownloadLogger.getAllText(displayInfo.gameId) + val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("Download Log", text)) + }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.copy), + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.copy), + style = MaterialTheme.typography.labelSmall, + ) + } + // Clear button + TextButton( + onClick = { DownloadLogger.clear(displayInfo.gameId) }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.clear), + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.clear), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + Surface( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + val logScrollState = rememberScrollState() + LaunchedEffect(downloadLog.size) { + logScrollState.animateScrollTo(logScrollState.maxValue) + } + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(logScrollState) + .padding(8.dp), + ) { + downloadLog.forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = MaterialTheme.typography.bodySmall.lineHeight.times(1.3f), + ) + } + } + } + } } } diff --git a/app/src/main/java/app/gamenative/utils/DownloadLogger.kt b/app/src/main/java/app/gamenative/utils/DownloadLogger.kt new file mode 100644 index 0000000000..a7f7240ea0 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/DownloadLogger.kt @@ -0,0 +1,178 @@ +package app.gamenative.utils + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +/** + * Independent download log system, decoupled from DownloadInfo. + * Features: + * - Per-appId log storage with batched disk writes (every 200 entries) + * - StateFlow for reactive UI updates (throttled to every 50 entries) + * - Clear and copy support + */ +object DownloadLogger { + + private const val LOG_DIR = ".DownloadLogs" + private const val MAX_LOG_ENTRIES = 2000 + + // Throttling: persist to disk every N entries + private const val DISK_FLUSH_INTERVAL = 200 + // Throttling: update StateFlow every N entries + private const val FLOW_UPDATE_INTERVAL = 50 + + private val logFlows = ConcurrentHashMap>>() + private val logBuffers = ConcurrentHashMap>() + private val writeCounters = ConcurrentHashMap() + private val lastPersistTime = ConcurrentHashMap() + + private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + + private fun getLogFile(gameId: Int): File? { + val dir = app.gamenative.service.SteamService.getAppDirPath(gameId) + if (dir.isNullOrBlank()) return null + val logDir = File(dir, LOG_DIR) + if (!logDir.exists()) logDir.mkdirs() + return File(logDir, "download.log") + } + + /** + * Get the log flow for a game. Creates it if it doesn't exist. + * The flow will be pre-populated from persisted logs on disk. + */ + fun getLogFlow(gameId: Int): StateFlow> { + return logFlows.getOrPut(gameId) { + val persisted = loadFromDisk(gameId) + logBuffers[gameId] = persisted.toMutableList() + writeCounters[gameId] = AtomicInteger(0) + lastPersistTime[gameId] = AtomicLong(System.currentTimeMillis()) + MutableStateFlow(persisted) + } + } + + /** + * Append a log entry with timestamp. + * StateFlow updates are throttled (every FLOW_UPDATE_INTERVAL entries). + * Disk writes are batched (every DISK_FLUSH_INTERVAL entries or 10 seconds). + */ + fun append(gameId: Int, message: String) { + val timestamp = timeFormat.format(Date()) + val entry = "[$timestamp] $message" + + val buffer = logBuffers.getOrPut(gameId) { + writeCounters[gameId] = AtomicInteger(0) + lastPersistTime[gameId] = AtomicLong(System.currentTimeMillis()) + loadFromDisk(gameId).toMutableList() + } + + synchronized(buffer) { + buffer.add(entry) + if (buffer.size > MAX_LOG_ENTRIES) { + buffer.removeAt(0) + } + } + + val counter = writeCounters.getOrPut(gameId) { AtomicInteger(0) } + val count = counter.incrementAndGet() + + // Throttled StateFlow update + if (count % FLOW_UPDATE_INTERVAL == 0) { + flushFlow(gameId, buffer) + } + + // Batched disk persist + val lastTime = lastPersistTime.getOrPut(gameId) { AtomicLong(System.currentTimeMillis()) } + val now = System.currentTimeMillis() + val timeSinceLastPersist = now - lastTime.get() + if (count % DISK_FLUSH_INTERVAL == 0 || timeSinceLastPersist > 10_000L) { + lastTime.set(now) + persistToDiskAsync(gameId, buffer) + } + } + + /** + * Force flush pending StateFlow updates and disk writes. + * Call this when download completes or fails. + */ + fun flush(gameId: Int) { + val buffer = logBuffers[gameId] ?: return + synchronized(buffer) { + flushFlow(gameId, buffer) + } + persistToDiskAsync(gameId, buffer) + } + + private fun flushFlow(gameId: Int, buffer: MutableList) { + logFlows.getOrPut(gameId) { + MutableStateFlow(buffer.toList()) + }.value = buffer.toList() + } + + private fun persistToDiskAsync(gameId: Int, buffer: MutableList) { + // Run disk write on a background thread + Thread { + try { + val file = getLogFile(gameId) ?: return@Thread + synchronized(buffer) { + file.writeText(buffer.joinToString("\n")) + } + } catch (_: Exception) { + // silently ignore disk write failures + } + }.start() + } + + /** + * Clear all logs for a game. + */ + fun clear(gameId: Int) { + logBuffers.remove(gameId) + writeCounters.remove(gameId) + lastPersistTime.remove(gameId) + logFlows[gameId]?.value = emptyList() + deleteFromDisk(gameId) + } + + /** + * Get all log text as a single string (for copy). + */ + fun getAllText(gameId: Int): String { + val buffer = logBuffers[gameId] ?: return loadFromDisk(gameId).joinToString("\n") + synchronized(buffer) { + return buffer.joinToString("\n") + } + } + + /** + * Get log line count. + */ + fun getCount(gameId: Int): Int { + return logBuffers[gameId]?.size ?: 0 + } + + // --- Persistence --- + + private fun loadFromDisk(gameId: Int): List { + return try { + val file = getLogFile(gameId) ?: return emptyList() + if (file.exists()) file.readLines().takeLast(MAX_LOG_ENTRIES) else emptyList() + } catch (_: Exception) { + emptyList() + } + } + + private fun deleteFromDisk(gameId: Int) { + try { + val file = getLogFile(gameId) ?: return + if (file.exists()) file.delete() + } catch (_: Exception) { + // silently ignore + } + } +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index dd00a899ea..e0cd375105 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -181,6 +181,7 @@ 已禁用 复制 + 清空 稳定 兼容性 平衡 @@ -543,6 +544,7 @@ 下载中... 计算中... 下载失败,请重试 + 下载日志 有可用更新 游戏信息 状态 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e96128cb7..bc217a1872 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -147,6 +147,7 @@ Disabled Copy + Clear Stability Compatibility Intermediate @@ -567,6 +568,7 @@ Downloading... Calculating... Download failed. Please try again. + Download Log Update Available Game Information Status diff --git a/gradle.properties b/gradle.properties index ea79b04496..a42beddd4d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,6 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx8192m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects @@ -22,3 +21,9 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true + + + +# Limit heap to avoid OOM on low-RAM machines +org.gradle.jvmargs=-Xmx3584m -XX:MaxMetaspaceSize=512m +kotlin.daemon.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=384m